> Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ # Storage Operations Base URL: `https://api.fast.io/current/` Storage endpoints are available on both workspaces and shares. The API patterns are identical -- replace `workspace/{workspace_id}` with `share/{share_id}` in any path below unless noted as workspace-only or share-only. All endpoints require JWT authentication unless otherwise noted. Include the header `Authorization: Bearer {jwt_token}` with every request. --- ## Conventions - **Root folder:** Use the literal string `"root"` as the path parameter (e.g., `/storage/root/list/`) - **Trash folder:** Use `"trash"` to list trashed items (e.g., `/storage/trash/list/`) - **Node IDs:** OpaqueIds -- 29-character alphanumeric strings displayed with hyphens (e.g., `2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4`). Use as-is in API calls. - **Node ID prefixes:** `f` = file, `d` = folder, `n` = note - **Name length:** File, folder, and note names are **1-255 characters**. Every endpoint that creates or renames a node by name enforces this range and rejects a longer name with `1605 (Invalid Input)`. The count is characters, not bytes, so an accented, CJK, or emoji character each counts as one. - **Node types in responses:** `"file"`, `"folder"`, `"note"`, `"link"` (lowercase strings) - **Parent field:** Nodes at the storage root have `"parent": "root"`; nested nodes show the parent's OpaqueId - **Delete vs purge:** `DELETE .../storage/{node_id}/delete/` moves to trash. `DELETE .../storage/trash/delete/` empties the entire trash. `DELETE .../storage/{node_id}/purge/` permanently deletes a single trashed item. - **Workspace folder shares:** Shares that reference a workspace folder have their `root` mapped to the designated folder. All operations are scoped to that subtree. - **Compact responses:** Every storage endpoint that returns nodes (list, details, search, metadata, trash, quickshares) accepts an optional `?output=` query parameter with three detail levels: `terse`, `standard`, or `full`. See the "Compact Responses" section below for the full contract, field lists, and the HTTP 406 rule for multi-level combinations. (QuickShare creation is **deprecated** — use the durable **File Share** instead.) - **Reading the error tables:** four-digit `16xx`/`17xx` values are **HTTP-status classes, not `error.code`**. The `error.code` a client actually receives is assigned per endpoint, so **use the HTTP status as the gate and a documented `error.code` — five or six digits, plus the `9661`-`9669` family — only as a refinement**. A `16xx` value identifies the status class — useful for telling which kind of failure occurred — but comparing one against `error.code` will never match. Codes shown as five or six digits (and the `9661`-`9669` family) ARE `error.code` values. **If you widen a check from a specific code to a status, widen what you assert with it** — a status covers failures the narrower code did not, so a message written for that one code becomes a confident falsehood on the rest. --- ## Compact Responses (`output=`) Every storage endpoint that returns node objects — folder listings, node details, search hits, metadata endpoints, trash listings, and quickshares — accepts an optional `output` query parameter that selects the shape of each node in the response. A single detail-level token may be combined with modifier tokens; specifying two detail levels (e.g. `?output=terse,standard`) returns **HTTP 406**. When `output=` is omitted, responses are `full` and byte-for-byte unchanged. | Level | Fields returned on each node (cumulative) | |-------|-------------------------------------------| | `terse` | `id`, `type`, `name`, `parent`, `version`, file/note-only `mimetype` and `size`, `modified`, recursive `nodes` for folders when the endpoint returns children, file/note-only `summary` reduced to `{title}` only, file/note-only `previews` reduced to a per-type `{ready: bool}` map (keys preserved: `thumbnail`, `image`, `pdf`, `mp4`, `hlsstream`, `audio`, `spreadsheet`), folder-only `is_share_root`/`share_id`, file/note-only `metadata_facts` reduced to a comma-separated `fields` name list | | `standard` | terse + `created`, `restricted`, `dmca`, `locked`, file/note-only `mimecategory`, `origin` reduced to `{creator, type}`, file/note-only `ai` reduced to `{state}`, file/note-only `metadata` reduced to `{title, short}` (user-authored overrides), `deleted` and `deleted_from` (present ONLY while the node is in the trash — on a live node they are absent at every tier, including `full`, so their absence never means "not deleted" for a node you have not checked), `is_imported`, link-only `target_type`/`target_id`, `metadata_facts` reduced to `field`, abbreviated `value`, and `value_truncated` | | `full` | standard + `summary.short`/`summary.long`, note-only `summary.category`, `virus`, full `ai` object, full `file_attributes` (embedded EXIF / media metadata, returned only to callers permitted to download the file -- see *Node Object Schema*), all remaining `origin.*` fields, `hash`, `hash_algo`, `lock_info`, `import_metadata`, full `previews` state map, full `metadata_facts` items (complete fact records) | **A tier is a CEILING, not a guarantee — read every row above as "at most these fields".** A key is present only when the node actually has it, so the same tier returns a different key set for a file, a folder, and a link. File/note-only: `mimetype`, `size`, `summary`, `previews`, `metadata_facts`, `mimecategory`, `ai`, `metadata`, `hash`, `hash_algo`, `file_attributes`. Folder-only: `is_share_root`, `share_id`, recursive `nodes`. Link-only: `target_type`, `target_id`. Trashed-only: `deleted`, `deleted_from`. The gap is large — at `full`, a file returns 27 keys and a folder 17 — so **treat a missing key as "not applicable to this node type", never as a null value or an error**, and never infer a node's state from a key's absence. Use `terse` for list rows, tree rendering, pickers, breadcrumb navigation, and drag-and-drop targets — it carries `modified` (so list rows can render the date column and "sort by modified" without a follow-up fetch), a per-type `previews` readiness map (so the thumbnail selector can pick the best available source), and the summary title. Use `standard` for most detail views, file-browser main lists, and any UI that shows AI-processing state, lock/restricted chips, DMCA chips, import-provider chips, or trash state — it adds the `ai.state` that drives the "summarizing…" spinner, timestamps, `origin` creator plus type, the `dmca` flag for DMCA chip rendering, `is_imported` for import-provider chips, link-node `target_type`/`target_id` discriminators, and the `metadata.title`/`metadata.short` user overrides that list rows render when a custom title is set. Use `full` (or omit the parameter) for the node detail pane, virus/AI inspection, version history, and any workflow that reads long-form summaries, EXIF, import provider metadata, or content hashes. Unknown tokens are silently ignored. Add the `markdown` modifier (e.g. `?output=standard,markdown`) to receive the response as GitHub-flavored Markdown (`Content-Type: text/markdown; charset=UTF-8`) instead of JSON — see the cross-cutting `?output=` reference in `llms.txt` for the full contract. --- ## Node Object Schema All endpoints that return node data use this format. Fields vary by node type. | Field | Type | Present On | Description | |-------|------|------------|-------------| | `id` | string | all | OpaqueId of the node | | `name` | string | all | File, folder, or note name | | `type` | string | all | `"file"`, `"folder"`, `"note"`, or `"link"` | | `parent` | string | all | Parent folder OpaqueId or `"root"` | | `size` | integer | file | File size in bytes | | `hash` | string | file | Content hash of the file | | `hash_algo` | string | file | Hash algorithm (e.g., `"md5"`) | | `mimetype` | string | file | MIME type (e.g., `"application/pdf"`) | | `mimecategory` | string | file | MIME category (e.g., `"document"`, `"image"`) | | `version` | string | file, note | Current version identifier — an OpaqueId in the hyphenated form (e.g., `"3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5"`). Send it back as `if_version_id` to make a write conditional | | `created` | string | all | Creation timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `modified` | string | all | Last-modified timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `restricted` | boolean | all | Whether the file has been restricted | | `dmca` | boolean | all | Whether the file has a DMCA flag | | `locked` | boolean | all | Whether the node has an active lock | | `lock_info` | object/null | all | Lock details when locked; `null` otherwise. `{"locker_uid": "...", "locked_at": "...", "expires_at": "...", "locker": {"display_name": "...", "agent_name": "...", "agent_name_source": "..."}}`. `locker.display_name` is the holder's name, or `null` when it cannot be resolved. `locker.agent_name` names the agent that took the lock on that account's behalf, when one did, and `locker.agent_name_source` says where that name came from; both are `null` when a person took the lock directly. **`agent_name` is self-declared, not verified** -- display it beside the holder, never rely on it to identify or authorize anyone. **It belongs to the CREDENTIAL, not the lock**: it is read from the JWT claim or API-key label set when you signed in or minted the key, there is no per-lock parameter for it, and every lock that credential takes carries the same label -- see *Lock Status* below. **Identity requires MEMBER level or above**: any caller below member -- including share guests and public-link recipients, not only outsiders -- receives `lock_info: null` while `locked` stays truthful, so they still learn the file is held without learning by whom | | `virus` | object | file | Virus scan status: `{"status": "scanned", "infected": false}` | | `file_attributes` | object | file | Metadata read out of the file itself -- `media_metadata` and/or `exif_metadata` when present. Returned only to callers permitted to download the file; see *Embedded File Metadata* below | | `summary` | object | file | AI-generated summary: `{"title": "...", "short": "...", "long": "..."}` | | `metadata` | object/null | file | User-defined custom title and description overrides | | `metadata_facts` | object | file, note | Extracted metadata facts recorded for the node -- `count`, `total`, `is_truncated`, and a payload shaped by the `output` level (`items` on `full` and `standard`, a comma-separated `fields` name list on `terse`). Returned only to WORKSPACE members; never present in a share context; see *Extracted Metadata Facts* below | | `previews` | object | file | Preview generation state per type (e.g., `{"thumbnail": {"state": "ready"}}`) | | `ai` | object | file | AI processing state: `{"state": "...", "attach": true/false}` | | `origin` | object | file | Origin info: `{"type": "upload", "creator": "{user_id}"}` | ### Embedded File Metadata (`file_attributes`) `file_attributes` carries metadata read out of the file's own bytes: `exif_metadata` (camera and lens details, device make and model, authoring software, author and copyright text) and `media_metadata` (container, codec, and per-stream details). Because that content comes from inside the file, it is returned **only to callers who are permitted to download the file**. - When the caller may view a file but not download it, the `exif_metadata` and `media_metadata` keys are **omitted**, so `file_attributes` comes back as an empty object `{}`. It is never `null`, no error is raised, and no other field changes. - Everything else reported about a file -- `size`, `hash`, `hash_algo`, `mimetype`, `mimecategory`, `created`, `modified`, `origin`, `previews`, `virus`, `ai`, and the AI `summary` -- is derived by the platform rather than read from the file, and is returned regardless of download rights. - **Workspace responses are unaffected.** Workspace members always have download rights, so workspace endpoints return `file_attributes` exactly as before. - On a **share**, the gate follows the share's file download permission: a share whose downloads are turned off (and receive-share guests, who are upload-only) get `{}`. Members and administrators always download, so they always receive it. - On a **File Share** link, the gate follows the link's `effective_capability`: `view` alone gets `{}`; `download` and `edit` receive the metadata. ### Extracted Metadata Facts (`metadata_facts`) `metadata_facts` carries the extracted metadata facts recorded for a file: the `field`-and-`value` pairs the extraction pipeline produced against the workspace's field vocabulary. It is returned on `file` and `note` nodes at all three `?output=` levels, in a different shape at each level, and every shape carries `count` -- how many facts this payload carries, **not** how many the node holds -- `total` -- how many the node holds, counted before any cap -- and `is_truncated`. Folder and link nodes never carry it, and the lightweight recent-files listing does not include it. **If you send no `output` parameter you get `full`** -- on node listings and on node details alike. There is one global default and no endpoint overrides it, so the two cannot diverge: a node arriving from a folder listing carries the same complete fact records as the same node fetched directly. The lean and projected shapes below are reachable only by asking for them explicitly. - **`full`** -- up to 100 facts as complete records: `field`, `value`, `declared_type`, `stored_type`, `source` (one of `ai`, `user`, `exif`, `mediainfo`, `validated_server`), `confidence` (`low`, `medium`, `high`, `certain`, or `null`), `rationale` (a string or `null`), and `updated` (`YYYY-MM-DD HH:MM:SS UTC`). A fact is identified by its `field` name -- no id of any kind is returned. ```json "metadata_facts": { "count": 2, "total": 2, "is_truncated": false, "items": [ { "field": "invoice_number", "value": "INV-1042", "declared_type": "string", "stored_type": "string", "source": "ai", "confidence": "high", "rationale": "Read from the header block on page 1", "updated": "2026-08-20 14:02:11 UTC" }, { "field": "amount_due", "value": 4820.5, "declared_type": "float", "stored_type": "float", "source": "ai", "confidence": "certain", "rationale": null, "updated": "2026-08-20 14:02:11 UTC" } ] } ``` - **`standard`** -- up to 8 facts carrying `field`, an abbreviated `value`, and a `value_truncated` boolean, with no provenance. The 8 are chosen in a fixed priority order -- typed values first (numbers, dates, booleans), then identifier fields (names ending `_number`, `_id`, `_code`, `_reference`), then everything else, alphabetical by field name within each group -- so a monetary or date fact is never cut in favour of an address or contact string. `value_truncated` is always present at this level. A string value longer than 64 characters is cut to 64 characters with `…` appended and `value_truncated` is `true`; a complete string, number, boolean or `null` value is returned unchanged with `value_truncated: false`. A `json`-typed **list** value is returned as a real array (no longer rendered as a JSON string): the array is whole, with `value_truncated: false`, when its JSON encoding is 64 characters or shorter -- counted on the value's own characters, with unicode and `/` counted unescaped rather than on an escaped-for-transmission byte form -- and otherwise holds the longest leading run of elements whose encoding fits that same budget (at least one element) with `value_truncated: true` -- elements present are exact, except that when the first element alone does not fit, it is cut so the one-element array's own encoding fits the 64-character budget -- the kept text comes out shorter than 64 characters (for example 59 plus `…` for a plain string) -- cutting the string itself for a string element or the element's JSON encoding for any other type, whatever the element's own type (a number, boolean, `null`, nested object or array all reduce to that same cut string). A `json`-typed **object** value is returned as the structure itself when its encoding is 64 characters or shorter by the same measure, and otherwise as a cut JSON preview **string** with `value_truncated: true`. Use `full` when you need the exact value or its type. **Never paste a `value_truncated: true` value into a `metadata_filters` equality predicate -- it cannot match; call `full` (or the node facts endpoint) for the complete value.** **The same key name reappears elsewhere under a different rule: `results[].matched_fields[].value_truncated` on *Metadata Search* below cuts to a window taken around the matched text, with no ellipsis appended -- do not apply one "strip the ellipsis" handler to both fields.** **Because each tier caps independently, `count` is not comparable across tiers for the same node:** a node holding 9 facts can report `"count": 9, "is_truncated": false` at `terse` (under its 20-name cap) and `"count": 8, "is_truncated": true` at `standard` (capped at 8) for the identical set of facts -- both are correct, and `is_truncated` signals that more facts exist beyond what was returned. **`total` IS comparable across tiers**, because it counts what the node holds rather than what the tier emitted: the same node reports `"total": 9` at both levels. Compare totals across tiers, never counts. ```json "metadata_facts": { "count": 8, "total": 14, "is_truncated": true, "items": [ {"field": "abstract", "value": "This master services agreement between Northwind and Con…", "value_truncated": true}, {"field": "amount_due", "value": 4820.5, "value_truncated": false} ] } ``` - **`terse`** -- up to 20 field **names** and no values at all, as one comma-separated string under a `fields` key (`fields`, not `items`), in the same priority order as `standard` (typed values, then identifiers, then the rest; alphabetical within each group). **Splitting that string on `, ` is safe:** a field name may contain only letters, digits, spaces, underscores and hyphens (Unicode-aware, so accented and non-Latin names are fine), so a comma can never appear inside a name. That charset is enforced wherever a name enters the vocabulary, including names an AI proposes during extraction. **Which characters are allowed is a different question from which names are the SAME name:** field names are compared case-, accent- and width-insensitively, so `Category`, `category` and `catégory` are ONE field, not three — the first spelling written owns the name and a later write in another spelling resolves to it. ```json "metadata_facts": { "count": 5, "total": 5, "is_truncated": false, "fields": "abstract, amount_due, contract_type, currency, invoice_number" } ``` - **`is_truncated` is present at every level** and is `true` when the node holds more facts than were returned. To read the complete set, call the node facts endpoint -- `GET /current/workspace/{workspace_id}/storage/{node_id}/metadata/facts/` -- which is not paginated and returns every fact on the node in one call. - **`total` is present at every level** and is how many facts the node holds, counted before any cap. `count` is what this payload carries; `total` is what there was to carry; `total` is never less than `count`. Read the pair as "8 shown of 14" -- that is what tells you whether re-reading the node at `?output=full` is worth a second call, and when `total` equals `count` there is nothing more to fetch. The node facts endpoint's own wrapper does not carry it, and does not need to: that read is uncapped, so its `count` already is the total. - **An absent `metadata_facts` does not mean the file has no metadata.** The key is **omitted entirely -- never `null`** -- when the caller is not entitled to extracted metadata, or when the facts could not be read for that request. A file that genuinely holds none returns `"count": 0, "total": 0` with an empty `items` (or an empty `fields` string) instead. - **Extracted metadata is a WORKSPACE-ONLY surface.** It is returned only to members of the owning workspace, because both the values and the field names are that team's own data -- the field vocabulary is what the team decided is worth recording about its documents, and it is not present in the file's bytes. It is **never** returned in a share context, to any share role: not to share administrators or members, not to share guests or public-link guests, and not to File Share link recipients. A workspace-folder share mirrors workspace nodes, and those nodes are covered by the same rule. ### AI States | Value | Description | |-------|-------------| | `disabled` | AI processing is disabled for this file | | `pending` | Queued for AI processing | | `inprogress` | AI processing is running | | `ready` | AI processing complete | | `failed` | AI processing failed | | `indexed` | File has been indexed for search and RAG | --- ## Keyset Pagination (Storage List) Storage listing endpoints (`list` and `recent`) use cursor-based pagination, not offset-based. **Request parameters:** | Parameter | Type | Default | Description | |-----------|--------|---------|-----------------------------------------------------| | `sort_by` | string | `name` | One of: `name`, `updated`, `created`, `type` | | `sort_dir` | string | `asc` | One of: `asc`, `desc` | | `page_size` | int | `100` | Must be `100`, `250`, or `500`; other values rejected | | `cursor` | string | -- | Opaque cursor string from previous response | **Response pagination fields:** | Field | Type | Description | |--------------------------|--------------|--------------------------------------| | `pagination.has_more` | boolean | Whether more pages exist | | `pagination.next_cursor` | string/null | Cursor for the next page; `null` if last page | | `pagination.page_size` | integer | Effective page size used | **Notes:** - Cursors are HMAC-signed; tampered cursors are rejected with an error. - When using a cursor, the page size from the cursor takes precedence over the request parameter. - Results for the first page may be slightly delayed. - The `recent` endpoint ignores `sort_by` and `sort_dir` (always sorted by `updated` descending). --- ## List Folder Contents ``` GET /current/workspace/{workspace_id}/storage/{parent_id}/list/ GET /current/share/{share_id}/storage/{parent_id}/list/ ``` List the contents of a folder. Uses keyset pagination (see above). **Auth required.** Permission: View (workspace), Guest+ (share). Share `list` on public shares may not require JWT. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{parent_id}` | string | Yes | Folder OpaqueId, `"root"`, or `"trash"` | **Query parameters:** See Keyset Pagination section above. **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/root/list/?sort_by=name&sort_dir=asc&page_size=100" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "nodes": { "count": 2, "items": [ { "id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "type": "folder", "name": "Documents", "parent": "root", "created": "2025-01-01 00:00:00 UTC", "modified": "2025-01-20 14:45:00 UTC" }, { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "photo.jpg", "parent": "root", "size": 2048000, "mimetype": "image/jpeg", "created": "2025-01-10 09:00:00 UTC", "modified": "2025-01-10 09:00:00 UTC" } ] }, "pagination": { "has_more": true, "next_cursor": "eyJwIjoiMmFiYzEyMy4uLiIsInMi...", "page_size": 100 } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `nodes.count` | integer | Number of nodes in this page | | `nodes.items` | array | Array of node objects for current page | | `pagination.has_more` | boolean | `true` if more pages exist | | `pagination.next_cursor` | string/null | Cursor for next page | | `pagination.page_size` | integer | Actual page size used | **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Folder not found | | `1605 (Invalid Input)` | 406 | Node is not a folder | | `1605 (Invalid Input)` | 406 | Invalid pagination cursor (tampered or mismatched) | --- ## Workspace Inventory ``` GET /current/workspace/{workspace_id}/storage/inventory/ GET /current/share/{share_id}/storage/inventory/ ``` Enumerate **every live node** in a workspace or share -- files, folders, notes and links -- as one flat, paged list covering the whole tree. There is no folder scope and no recursion to drive: one walk returns everything. Trashed nodes are excluded. **This is the intended way to enumerate every live file.** Listing the tree with `/storage/{parent_id}/list/` costs one call per folder, and the number of folders cannot be known in advance. An inventory row is deliberately lightweight, so enumerating a whole workspace is much cheaper per file than listing folders -- reach for this endpoint instead of walking the tree whenever you want "everything in this workspace". Rows are ordered by **node id ascending** and walked with a keyset cursor. Ordering by id is what makes the walk stable: renaming or editing a file between pages does not move it, so it is neither skipped nor served twice. **The default `page_size` is `100` because that is what an AI agent can render whole.** An agent renders a page into a context window under a ceiling of roughly 35,000 characters, and a lean inventory row costs about 324 rendered characters -- about 386 with `include=path` -- so a page of 100 arrives complete while a larger one has to be truncated, and a truncated enumeration is indistinguishable from a complete one. Use `250` or `500` when the caller is not an agent -- a sync process or a UI has no rendering ceiling and wants the round trips instead. The cursor is **the id of the last row you kept** — not an opaque token. The comparison is **strictly greater than**, so that row is not repeated, and it is **independent of `type` and `page_size`**, both of which may change between pages. This is deliberate: a client that renders a page and truncates it stopped somewhere the server does not know, and an opaque cursor would make it skip every row it dropped. Rows are **terse by design**. There is no `output=` parameter on this endpoint and it is not accepted. A caller that wants a file's full record already has its id -- read it with `/storage/{node_id}/details/`. **Auth required.** Permission: View (workspace), Guest+ (share). Share `inventory` on public shares may not require JWT. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | **Query parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `page_size` | integer | No | `100` | Must be `100`, `250`, or `500`. See the note below on why the default is `100`. | | `cursor` | string | No | - | The `id` of the last row you kept, hyphenated or raw. Absent means the first page. Strictly greater-than, and independent of `type` and `page_size`. A value that is not a node id is refused with `406`. | | `type` | string | No | - | Restrict the walk to one node type: `file`, `folder`, `link`, or `note` | | `include` | string | No | - | Comma-separated extra field groups. Only `path` is recognised. An unrecognised token is **refused with `406`**, never silently ignored. | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/inventory/?type=file" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "nodes": { "count": 2, "items": [ { "id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "type": "folder", "name": "Documents", "parent_id": "root", "size": null, "mimetype": null, "updated": "2025-01-20 14:45:00 UTC", "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "summary": null, "facts_total": null }, { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "Q4 Report.pdf", "parent_id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "size": 2048000, "mimetype": "application/pdf", "updated": "2025-01-10 09:00:00 UTC", "version": "3gzfc-3x7gt-4xsnm-qw52d-sjzcx-cuxa", "summary": { "title": "Q4 revenue and headcount summary" }, "facts_total": 12 } ] }, "pagination": { "has_more": true, "next_cursor": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "page_size": 100 } } ``` The envelope uses the **same key names as `/storage/{parent_id}/list/`** (`nodes.count`, `nodes.items`, `pagination.*`), so a client that already pages folder listings reuses its parser unchanged. **Response fields:** | Field | Type | Description | |-------|------|-------------| | `nodes.count` | integer | Number of rows in this page | | `nodes.items` | array | The inventory rows for this page | | `nodes.items[].id` | string | Node OpaqueId | | `nodes.items[].type` | string | `"file"`, `"folder"`, `"link"`, or `"note"` | | `nodes.items[].name` | string | Node name | | `nodes.items[].parent_id` | string | Parent folder OpaqueId, or `"root"` for a node directly in the storage root | | `nodes.items[].size` | integer/null | Size in bytes for files and notes; `null` for folders and links | | `nodes.items[].mimetype` | string/null | MIME type for files and notes; `null` otherwise | | `nodes.items[].updated` | string | Last-modified time, `YYYY-MM-DD HH:MM:SS UTC` | | `nodes.items[].version` | string | Current version OpaqueId | | `nodes.items[].summary` | object/null | `{"title": "..."}` when the file has an AI summary, otherwise `null`. **Title only** -- the short and long forms stay on `details/`. | | `nodes.items[].facts_total` | integer/null, or absent | How many extracted metadata fields the file has. **Emitted only for callers who may read metadata facts (workspace members)** -- absent for everyone else, and never emitted at all by the share twin. When present: an integer count, or `null` when the file has never had metadata extracted. Folders and links are always `null` when the key is present; notes follow the same rule as files. See the note below -- an absent key means *unknown*, never zero. | | `pagination.has_more` | boolean | `true` if more pages exist | | `pagination.next_cursor` | string/null | Cursor for the next page; `null` when there are no more pages | | `pagination.page_size` | integer | Effective page size used | **`facts_total` -- absent, `null` and a number mean three different things.** The field is gated on permission to read metadata facts, and facts never cross into a share, so it is emitted only for callers who may read them (workspace members) and the **share twin never emits it at all**. Read the three states separately: - **Absent** -- unknown, or not permitted for this caller. The key is also absent when the count is not available for this response. Treat it as *unknown*: never as `0`, and never as "this file has no metadata". - **`null`** -- the key is present and the file has never had metadata extracted. Folders and links are always `null` when the key is present; notes follow the same rule as files. - **A number** -- that many extracted metadata fields. **`include=path` -- where each node lives.** Add `include=path` and every row gains three more fields: `path` (string/null), `ancestors` (array of `{id, name}`), and `path_complete` (boolean). They have **exactly the same meaning and shape** as the fields of the same names on a storage-search hit -- read *Folder paths* under the *Search* section below for the full contract, including why `path_complete` is the field the other two are read through. `include` is the only way to get them here; an `include` token other than `path` is refused with `1605 (Invalid Input)` / `406` rather than ignored, so a typo can never look like "the field is unavailable". **Share twin.** Inventory is available on **independent-storage shares only**. A share backed by a workspace folder refuses it with `1609 (Not Found)` / `404` and the message *"Inventory is not available for Shared Folders"* -- the same restriction the share `storage/search/` twin has. Enumerate such a share's contents through the backing workspace instead. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | `cursor` is not a node id | | `1605 (Invalid Input)` | 406 | Unsupported `include` value | | `1609 (Not Found)` | 404 | Inventory is not available for Shared Folders (share only, workspace-folder shares) | --- ## Node Details ``` GET /current/workspace/{workspace_id}/storage/{node_id}/details/ GET /current/share/{share_id}/storage/{node_id}/details/ ``` Get full details for a single node (file, folder, or note). **Auth required.** Permission: View (workspace), View (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | Node OpaqueId | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "format": "single", "node": { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "document.pdf", "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "size": 5242880, "hash": "d41d8cd98f00b204e9800998ecf8427e", "hash_algo": "md5", "mimetype": "application/pdf", "mimecategory": "document", "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "created": "2025-01-15 10:30:00 UTC", "modified": "2025-01-20 14:45:00 UTC", "restricted": false, "dmca": false, "locked": false, "lock_info": null, "virus": { "status": "scanned", "infected": false }, "file_attributes": { "width": null, "height": null, "duration": null }, "summary": { "title": "Quarterly Report", "short": "Q4 financial summary", "long": "Comprehensive financial report covering revenue and expenses." }, "metadata": null, "previews": { "thumbnail": { "state": "ready" }, "pdf": { "state": "ready" } }, "ai": { "state": "indexed", "attach": true }, "origin": { "type": "upload", "creator": "9876543210987654321" } } } ``` **Response fields:** See Node Object Schema above for complete field reference. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found | | `1680 (Access Denied)` | 401 | No permission to view details (share only) | ### Bulk Form The `details` endpoint accepts a comma-separated list of node ids in place of a single `{node_id}`: ``` GET /current/workspace/{workspace_id}/storage/{id1},{id2},{id3}/details/ ``` Up to **25** ids per call. Duplicate ids are silently deduplicated. Empty segments (e.g. trailing comma) return `406`. The bulk form is currently only available for the workspace path. The bulk response shape differs from the single-id form. Successfully resolved nodes appear as an array under `nodes`; per-id failures appear in a parallel `errors` array. HTTP status is `200` when at least one node resolves and `404` when every requested id errored. ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/abc123,def456,ghi789/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (HTTP 200):** ```json { "result": true, "format": "multi", "nodes": [ { "id": "abc123", "type": "file", "name": "report.pdf" }, { "id": "def456", "type": "folder", "name": "Documents" } ], "errors": [ { "node_id": "ghi789", "code": 133123, "message": "No such file or folder exists" } ] } ``` Every details response carries a top-level `format` field — `"single"` (single-id form, paired with a `node` object) or `"multi"` (bulk form, paired with `nodes` + `errors` arrays). Clients can branch on `format` instead of inferring shape from key presence. | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Empty segment between commas | | `1605 (Invalid Input)` | 406 | More than 25 unique ids in one request | | `1609 (Not Found)` | 404 | Every requested id errored (errors array is populated) | --- ## Node Content (extracted text) ``` GET /current/workspace/{workspace_id}/storage/{node_id}/content/ GET /current/share/{share_id}/storage/{node_id}/content/ ``` Return a file's or note's **extracted text** as ordered chunks -- the same text the platform already extracted and indexed for search and AI. `read/` hands back raw bytes and search returns only a snippet, so this is the route that lets a caller actually read a PDF's words. **The unit is a chunk, not a page.** Text is chunked for retrieval, and a chunk can span two pages or split one, so `page=` returns the whole chunks that OVERLAP that page -- never a page-shaped blob of text. Pages are known only for formats that carry them (documents that were paginated when they were converted); spreadsheets, plain text, code, and notes have no pages and are addressed by `chunk_from`/`chunk_to` instead. Read `page_addressable` before sending a `page`. Audio and video files are not served by this route today and answer `indexed: false`. **A chunk's address is its `position`.** `position` is the chunk's 0-based place in the file's read order, and it is what `chunk_from`/`chunk_to` select on. `chunk_index` is the older name for that idea and is now nullable -- newer text may not carry one -- so address chunks by `position` and treat `chunk_index` as legacy. `sequence` is a different coordinate, assigned when the file's text was extracted: it rises through the file without being contiguous, is `null` on text extracted before it was assigned, and is published for correlation rather than addressing. `chunk_from`/`chunk_to` reach the first 10000 positions of a file; a position at or beyond `10000` cannot be addressed with them and is refused as an invalid window -- keep walking a large file with `cursor`, which has no such limit. Only the file's **current version** has text. `indexed_version_id` names the version the returned chunks were indexed from. A cursor walk is not a snapshot: a cursor is stamped with the version it was issued against, so if the file is replaced mid-walk, the next call with the old cursor returns an EMPTY `chunks` list with `next_cursor: null`. `indexed_version_id` shows the new version once its text is indexed, and is `null` while it is still being processed -- restart from the beginning either way, rather than splicing old text onto new. **Auth required.** Permission: View (workspace). The share path requires **download** permission, not view -- extracted text is the interior of the file, so a guest who may not fetch the bytes may not read the text either. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | Node OpaqueId. Both spellings are accepted -- with or without hyphens. Ids in the response are always hyphenated. | **Query parameters:** All optional. At most **one** window selector (`page`, `chunk_from`/`chunk_to`, `q`); with none, the response starts at the beginning of the file. | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `q` | string | No | -- | **Relevance mode.** Rank this file's own chunks by keyword match and return the best ones, each with full text and a numeric `score`. 1-512 characters. Cannot be combined with `page`, `chunk_from`/`chunk_to`, or `cursor`. | | `page` | integer | No | -- | Return the chunks whose `[start_page, end_page]` range overlaps this page. 1-based. | | `chunk_from` | integer | No | -- | Start of an inclusive `position` range. Minimum `0`. Positions at or beyond `10000` cannot be addressed this way -- continue into a large file with `cursor` instead. | | `chunk_to` | integer | No | -- | End of that inclusive `position` range. Requires `chunk_from` and must be greater than or equal to it, and is subject to the same `10000` ceiling. | | `cursor` | string | No | -- | Continue after this chunk within the selected window. Opaque; pass back the `next_cursor` from the MOST RECENT response verbatim, never a value you built yourself and never one you stored from an earlier walk. Not valid with `q`. When used together with `chunk_from`/`chunk_to`, the cursor must have come from a walk of that same range: a token whose position falls outside the range is refused as an invalid window. | | `limit` | integer | No | `5` (`3` with `q`) | Chunks per response. `1`-`20`. | | `max_bytes` | integer | No | `32768` | UTF-8 **byte** budget over the text in one response, applied in the ordered modes only. `1024`-`262144`. | | `output` | string | No | `full` | `terse` returns the chunk map with no `text`; `standard` and `full` include it. Composable with the `markdown` modifier like every other storage endpoint. | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/content/?page=2&limit=5" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "name": "Master Services Agreement.pdf", "mimetype": "application/pdf", "indexed": true, "complete": true, "indexed_version_id": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "page_addressable": true, "num_pages": 3, "total_chunks": 3, "chunks": [ {"position": 0, "sequence": 0, "chunk_index": 0, "start_page": 1, "end_page": 1, "chars": 4000, "score": null, "text": "MASTER SERVICES AGREEMENT ..."}, {"position": 1, "sequence": 12, "chunk_index": 1, "start_page": 1, "end_page": 2, "chars": 4309, "score": null, "text": "... 5. TERM AND TERMINATION ..."} ], "next_cursor": "", "truncated": false } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `node_id` | string | The node, hyphenated. | | `name`, `mimetype` | string | Taken from the node itself, never from the text index, so a stale index entry can never change what the file is reported to be. | | `indexed` | boolean | Whether this version has any extracted text at all. Describes the WHOLE file, not the window you asked for. | | `complete` | boolean | Whether the file's text extraction finished. When `false`, only part of the text has been written so far -- the chunks you get are real, and more may appear later. | | `indexed_version_id` | string/null | The file version the chunks were indexed from. `null` when `indexed` is `false`. | | `page_addressable` | boolean | `true` only when EVERY chunk of the file carries a page range. When `false`, a `page` selector returns an empty `chunks` list -- switch to `chunk_from`/`chunk_to`. | | `num_pages` | integer/null | Page count of the converted document; `null` when the format has no pages. | | `total_chunks` | integer | Chunks in the whole file, not in this response. A window that matches nothing on an indexed file still returns `indexed: true` with the real `total_chunks` and an empty `chunks` list. | | `chunks` | array | The matching chunks, ordered by `position` (by `score` descending in relevance mode). | | `chunks[].position` | integer | **The chunk's address.** Its 0-based ordinal place in the file's read order. Always present and always exact. This is what `chunk_from` and `chunk_to` select on, and the value to record if you want to come back to a chunk later; `cursor` continues a walk from an opaque token instead. | | `chunks[].sequence` | integer/null | The ordering coordinate assigned when the file's text was extracted. It increases through the file but is not necessarily contiguous, and is `null` for text extracted before it was assigned. Published for correlation and debugging -- address a chunk by `position`, not by this. | | `chunks[].chunk_index` | integer/null | Legacy field, kept for compatibility and now nullable: newer text may not carry one. Read `position` as the address instead. | | `chunks[].start_page`, `chunks[].end_page` | integer/null | Inclusive 1-based page range the chunk covers; both `null` on a file with no pages. | | `chunks[].chars` | integer | Character length of the full chunk text -- present under `output=terse` too, so a caller can budget before asking for the text. | | `chunks[].score` | number/null | The keyword relevance score in `q` mode; `null` in every ordered mode. | | `chunks[].text` | string | The chunk's text. Absent under `output=terse`. | | `next_cursor` | string/null | Opaque continuation token for the last chunk actually emitted, to pass back as `cursor`. Treat it as an opaque string -- never construct, parse or store one, and always send back the token from the most recent response; a token held over from an earlier release is refused as an invalid window. It carries the file version it was issued against, so a walk can never straddle two versions. `null` when the window is exhausted -- that, not a short page, is how you know you are done. | | `truncated` | boolean | `true` when whole chunks were withheld by `max_bytes`. | **Reading a whole file:** call with no selector, then keep re-calling with `cursor` set to the previous `next_cursor` until `next_cursor` is `null`, concatenating `text`. Chunks are contiguous and in order; joining them with a newline between chunks reproduces the extracted text apart from the whitespace the splitter dropped at each boundary. **Ordered modes and the byte budget.** In the ordered modes (no selector, `page`, `chunk_from`/`chunk_to`) the response stops BEFORE the chunk that would push it past `max_bytes`; text is never cut inside a chunk, and at least one chunk is always returned even when that one chunk is larger than the budget. `truncated: true` means matched chunks were withheld -- not that a chunk's text was shortened -- and `next_cursor` is the last chunk actually sent, so continuing from it loses nothing. **Relevance mode (`q`).** `q` ranks this one file's chunks by keyword match (the terms are matched independently, so a natural-language question still returns the passage carrying the most of its words first). It returns the top `limit` chunks -- default 3 -- sorted by `score` descending, each with its FULL text: `max_bytes` is deliberately not applied, so a hit is never silently dropped. Use it to locate and quote a clause without walking the file; use the ordered modes to read the file through. **`output=terse`** returns the same envelope and the same chunk list with `text` omitted -- a chunk map of positions, page ranges and `chars`. The byte budget is not spent on a response that carries no text, so a terse page is never shortened. **A file with no extracted text** is a normal `200`, not an error: ```json { "result": true, "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "name": "scan.jpg", "mimetype": "image/jpeg", "indexed": false, "complete": false, "indexed_version_id": null, "page_addressable": false, "num_pages": null, "total_chunks": 0, "chunks": [], "next_cursor": null, "truncated": false } ``` `indexed: false` means this version has no text in the index -- it may never have been processed, processing may still be queued, or the format may carry no extractable text. It never means the read failed: a failure to read the index is a `500`, so `indexed: false` is always a statement about the file and never about the platform. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found, or the node is in the trash | | `1605 (Invalid Input)` | 406 | Node is a folder or a link -- only files and notes carry extracted text | | `1605 (Invalid Input)` | 406 | Window parameters conflict or are out of range: more than one selector, `q` with `cursor`, `chunk_to` without `chunk_from`, `chunk_from` greater than `chunk_to`, a `chunk_from`/`chunk_to` position at or beyond `10000`, a `cursor` that was not taken verbatim from the most recent response, a `cursor` used with a `chunk_from`/`chunk_to` range it does not fall inside, or a value outside the documented bounds | | `1680 (Access Denied)` | 401 | Share caller has no download permission, or the file is virus-flagged (share path) | | `1652 (Resource Not Found)` | 404 | The file's content is no longer available | | `1654 (Internal Error)` | 500 | Content temporarily unavailable -- the text could not be read. Retry; never treat this as "the file has no text" | --- ## Multi-File Content Search (relevance across several files) ``` GET /current/workspace/{workspace_id}/storage/content/ ``` Score **several named files** against one query in a single call and get back the passages that answered it. This is the relevance mode of the single-file route above, asked of up to ten files at once, so a caller assembling context for a prompt makes one request instead of ten. The chunk objects are identical to that route's, field for field, so the two can be mixed freely. Note the shape of the path: the file ids travel in the `nodes` query parameter, not in the path, so this route sits beside `search/` rather than under a `{node_id}`. **Each file is scored against itself.** Every file is ranked only among its own chunks, and the scores are not comparable BETWEEN files -- keyword relevance scores are only meaningful within one result set. Take the top chunks per file; do not merge the lists and re-sort them by `score`. **Auth required.** Permission: View (workspace) -- the same gate as the single-file route. Workspace only; there is no share form of this route. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit profile ID | **Query parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `nodes` | string | Yes | -- | Comma-separated node ids, **1-10** of them. Both spellings are accepted -- with or without hyphens. Blank segments are ignored (`a,,b` names two files) and a repeated id is de-duplicated silently. | | `q` | string | Yes | -- | The query the files are scored against. 1-512 characters. **Required here** -- this route publishes no other way to select text. | | `limit` | integer | No | `3` | Chunks returned **per file**. `1`-`20`. | | `max_bytes` | integer | No | `32768` | UTF-8 **byte** budget over the emitted text, spent **per file** -- same name, default and bounds as the single-file route's `max_bytes`, but ten files at the cap means ten separate budgets, not one shared pot: the response ceiling is `nodes × max_bytes`, roughly 320 KiB of text at ten files and the default. At the maximum `max_bytes`, the ceiling is `10 × 262144` ≈ 2.5 MiB of text -- a deliberate opt-in, equal to the single-file route's per-request bound times the node cap. `1024`-`262144`. Text is never cut inside a chunk -- a file's emission stops before the chunk that would overflow its budget, and at least one chunk is always returned. | | `output` | string | No | `full` | `terse` returns the chunk map with no `text`; `standard` and `full` include it. | There is no `page`, `chunk_from`/`chunk_to` or `cursor` here: those address a walk through ONE file, which the single-file route already serves. Follow a passage found here by calling that route with the `position` this one returned. **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/content/?nodes=2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4,2azz3-bexth-xvixb-cpq37-azkzx-xinq&q=retention%20policy&limit=3" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "q": "retention policy", "limit": 3, "nodes": { "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4": { "name": "Master Services Agreement.pdf", "mimetype": "application/pdf", "indexed": true, "indexed_version_id": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "page_addressable": true, "num_pages": 6, "total_chunks": 12, "chunks": [ {"position": 4, "sequence": 1005, "chunk_index": 4, "start_page": 3, "end_page": 4, "chars": 4102, "score": 7.25, "text": "... 9. RECORD RETENTION ..."} ], "truncated": false } }, "missing": [ {"id": "2azz3-bexth-xvixb-cpq37-azkzx-xinq", "reason": "trashed"} ] } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `q`, `limit` | string, integer | Echoed back as applied, so a caller can see the default `limit` that was used. | | `nodes` | object | Keyed by hyphenated node id, in the order the ids were named. An **object even when empty** -- a caller keying into it never has to handle a list. | | `nodes.{id}.name`, `nodes.{id}.mimetype` | string | Taken from the file itself, never from the text index. | | `nodes.{id}.indexed` | boolean | Whether this version has any extracted text at all -- describes the whole file, not the query window. | | `nodes.{id}.indexed_version_id` | string/null | The file version the returned chunks were indexed from. `null` when `indexed` is `false`. | | `nodes.{id}.page_addressable` | boolean | `true` only when EVERY chunk of the file carries a page range -- the same meaning as on the single-file route. | | `nodes.{id}.chunks` | array | That file's best-matching chunks, `score` descending, at most `limit` of them. Identical in fields and formatting to the single-file route's chunks: `position`, `sequence`, `chunk_index`, `start_page`, `end_page`, `chars`, `score`, `text`. `position` means the same thing and can be sent straight back to the single-file route as `chunk_from`. | | `nodes.{id}.total_chunks` | integer | Chunks in the WHOLE file, not in this response. A file that is indexed but matched nothing comes back with an empty `chunks` list and a non-zero `total_chunks`; a file with no extracted text at all comes back with `total_chunks: 0`. | | `nodes.{id}.num_pages` | integer/null | Page count of the converted document; `null` when the format has no pages. | | `nodes.{id}.truncated` | boolean | `true` when that file's chunks were cut short by `max_bytes` -- not by `limit`. A file whose text matched in more places than `limit` still returns `truncated: false`; a `chunks` length equal to `limit` means more matches may exist -- raise `limit` to see them. | | `missing` | array | One entry per named id the read could not answer for: `{id, reason}`. One unreadable id never costs you the others. | | `missing[].reason` | string | `not_found` (no such file in this workspace, or its content is gone), `trashed` (the file is in the bin), or `not_text` (a folder or a link, which carries no extracted text). | `complete` and `next_cursor` are deliberately not published here -- those describe a walk through ONE file, which this route does not do. Ask the single-file route when you need them. **A failure to read the text index is a `500`, never an empty `chunks` list.** An empty list always means those files hold nothing matching your query, and never that the platform could not look. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | `nodes` names no ids, names more than 10, or contains a malformed id; `q` is missing, empty or longer than 512 characters; `limit` is outside `1`-`20`; `max_bytes` is outside `1024`-`262144` | | `1654 (Internal Error)` | 500 | Content temporarily unavailable -- the text could not be read. Retry; never treat this as "these files have no matching text" | | `1654 (Internal Error)` | 500 | A named node could not be retrieved (a backend read failure at the node or physical-record lookup) -- never reported as `missing`/`not_found`; the whole request fails instead. | --- ## Add File from Upload ``` POST /current/workspace/{workspace_id}/storage/{parent_id}/addfile/ POST /current/share/{share_id}/storage/{parent_id}/addfile/ ``` Add a previously uploaded file to storage. **Auth required.** Permission: Guest (workspace), file creation permission (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{parent_id}` | string | Yes | Parent folder OpaqueId or `"root"` | **Request body (form-encoded):** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `name` | string | Yes | Filename for the new file. 1-255 characters (counted as characters, not bytes). | | `from` | string | Yes | JSON-encoded source object (see below) | **`from` format:** Both workspace and share variants accept the same JSON shape. Two source types are supported: `upload` (a completed upload session) and `hash` (deduplicate against an existing object by content hash). ```json {"type": "upload", "upload": {"id": "{upload_id}"}} ``` ```json {"type": "hash", "hash": {"hash": "{file_hash}", "hash_type": "sha256"}} ``` A `hash` source adds the file instantly only when its content already exists in that storage; if the content is not present, the call returns `404 Not Found` ("The specified file content was not found in this storage. Upload the file instead.") and the client should upload the file normally instead. **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/root/addfile/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'name=document.pdf' \ -d 'from={"type":"upload","upload":{"id":"abc123opaqueid"}}' ``` **Response:** ```json { "result": true, "node": { "id": "2emaf-exxpw-thkzj-5rlym-ocyoh-iufa", "type": "file", "name": "document.pdf", "parent": "root", "size": 5242880, "hash": "d41d8cd98f00b204e9800998ecf8427e", "hash_algo": "md5", "mimetype": "application/pdf", "mimecategory": "document", "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "created": "2025-01-15 10:30:00 UTC", "modified": "2025-01-15 10:30:00 UTC", "restricted": false, "dmca": false, "locked": false } } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Upload session not found or not associated with your account | | `1605 (Invalid Input)` | 406 | Upload is not complete | | `1609 (Not Found)` | 404 | Parent folder not found | | `1605 (Invalid Input)` | 406 | Parent node is not a folder | | `1609 (Not Found)` | 404 | Parent folder is in trash | | `1605 (Invalid Input)` | 406 | Name conflict (only when using FAIL strategy) | | `1693 (Temporarily Unavailable)` | 503 | Chunk manifest not yet durable — retry after a brief delay | **Notes:** - The upload session must be in COMPLETE status before adding the file. - Virus scanning occurs during upload assembly, not at this stage. - **Conflict resolution:** If a file with the same name exists, the default behavior is to **replace** (overwrite) the existing file, creating a version for rollback. Folder or type-mismatch conflicts fall back to renaming. --- ## Add Link (Workspace Only) ``` POST /current/workspace/{workspace_id}/storage/{parent_id}/addlink/ ``` Add a share link node to workspace storage. Link nodes represent references to shares within the workspace tree. **Auth required.** Permission: Guest. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit workspace profile ID | | `{parent_id}` | string | Yes | Parent folder OpaqueId or `"root"` | **Request body (form-encoded):** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `link_target_type` | string | Yes | Must be `"share"` | | `share` | string | Yes | Share identifier to link | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/root/addlink/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'link_target_type=share' \ -d 'share=my-share-name' ``` **Response:** ```json { "result": true } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1683 (Resource Missing)` | 404 | Share not found or not accessible | | `1605 (Invalid Input)` | 406 | Share does not belong to this workspace | | `1605 (Invalid Input)` | 406 | A link to this share already exists (only one per share) | | `1605 (Invalid Input)` | 406 | Share has no title or custom URL to name the link node with | | `1610 (General Error)` | 500 | The link node was created but the share's back-link could not be saved; the node is removed again and the request fails — retry | The link node is named after the share's title, falling back to its custom URL when no title is set. --- ## Create Folder ``` POST /current/workspace/{workspace_id}/storage/{parent_id}/createfolder/ POST /current/share/{share_id}/storage/{parent_id}/createfolder/ ``` Create a new folder. This endpoint is idempotent by default: if a folder with the same `name` already exists in the parent, the existing folder is returned (HTTP 200) instead of creating a duplicate. Pass `force=true` to bypass this and always create a new folder (auto-renamed on a name collision, e.g. `Documents (2)`). **Auth required.** Permission: Guest (workspace), folder creation permission (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{parent_id}` | string | Yes | Parent folder OpaqueId or `"root"` | **Request body (form-encoded):** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `name` | string | Yes | Folder name. 1-255 characters (counted as characters, not bytes). | | `force` | boolean | No | When `true`, always create a new folder even if one with this name already exists (auto-renamed on collision). Defaults to `false` (idempotent — returns the existing folder). | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/root/createfolder/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'name=Documents' ``` **Response:** ```json { "result": true, "node": { "id": "2uvd6-rylta-qajvp-y3yr6-fzadn-e4rc", "type": "folder", "name": "Documents", "parent": "root", "created": "2025-01-15 10:30:00 UTC", "modified": "2025-01-15 10:30:00 UTC", "restricted": false, "dmca": false, "locked": false } } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | A non-folder item already uses this name in the parent folder | | `1609 (Not Found)` | 404 | Parent folder not found | | `1605 (Invalid Input)` | 406 | Parent node is not a folder | | `1609 (Not Found)` | 404 | Parent folder is in trash | | `1680 (Access Denied)` | 401 | No folder creation permission (share only) | --- ## Create Note (Workspace Only) ``` POST /current/workspace/{workspace_id}/storage/{parent_id}/createnote/ ``` Create a markdown note. Notes are auto-indexed for AI when workspace intelligence is enabled. **Auth required.** Permission: Guest. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit workspace profile ID | | `{parent_id}` | string | Yes | Parent folder OpaqueId or `"root"` | **Request body (form-encoded):** | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `name` | string | Yes | 1-255 characters (counted as characters, not bytes); must end in `.md` | Note name | | `content` | string | Yes | Max 100 KB | Markdown content | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/root/createnote/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'name=meeting-notes.md' \ -d 'content=# Meeting Notes\n\nDiscussed project timeline.' ``` **Response:** ```json { "result": true, "note": { "id": "2ik5q-a43cm-uixi2-van5r-3eolo-7mue", "type": "note", "name": "meeting-notes.md", "parent": "root", "mimetype": "text/markdown", "created": "2025-01-15 10:30:00 UTC", "modified": "2025-01-15 10:30:00 UTC" } } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Filename must end with `.md` | | `1605 (Invalid Input)` | 406 | Duplicate name in parent folder | | `1609 (Not Found)` | 404 | Parent folder not found | --- ## Update Note (Workspace Only) ``` POST /current/workspace/{workspace_id}/storage/{node_id}/updatenote/ ``` Update an existing note's name and/or content. Updating content creates a new version. **Auth required.** Permission: Guest. This workspace endpoint also accepts a `realtime-note` bearer token (minted by `GET /current/realtime/note-auth/{profile_id}/{note_id}`) in place of a user JWT, for the collaborative-editing backend to save on the user's behalf. The token is bound to a specific note and workspace and is rejected (`403 Forbidden`) if it does not match the requested note or workspace, or if it carries only `view` permission (a view token cannot update). The share note endpoints do not accept this token. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit workspace profile ID | | `{node_id}` | string | Yes | Note OpaqueId | **Request body (form-encoded):** | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `name` | string | No | 1-255 characters (counted as characters, not bytes); must end in `.md` | New note name | | `content` | string | No | Max 100 KB, non-blank | New markdown content (an empty or whitespace-only value is rejected) | | `if_version_id` | string | No | Version OpaqueId | Compare-and-swap precondition. When supplied, the update only proceeds if the note's current `version` matches this value; otherwise the request is rejected with `409 Conflict` and no change is made. Use the `version` returned by a prior read or update as the value to guard against overwriting concurrent edits. `version` is returned at every detail level, including `?output=terse`, so a compact read is a valid base for a later conditional write. | At least one of `name` or `content` is required. **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/updatenote/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'content=# Updated Notes\n\nRevised content here.' ``` **Response:** ```json { "result": true, "note": { "id": "2ik5q-a43cm-uixi2-van5r-3eolo-7mue", "type": "note", "name": "meeting-notes.md", "parent": "root", "mimetype": "text/markdown", "created": "2025-01-15 10:30:00 UTC", "modified": "2025-01-20 14:45:00 UTC" } } ``` **Conflict response (`if_version_id` mismatch):** When `if_version_id` is supplied and does not match the note's current version, the request is rejected with HTTP `409 Conflict` and no version is created. `error.params` is a **list of parameter entries**, matching the structured detail used elsewhere in this API: ```json { "error": { "code": 113958, "text": "The note was modified since the supplied version", "params": [ { "name": "if_version_id", "kind": "conflict", "message": "The note was modified since the supplied version. current_version_id=3jnix-kx7lb-pf2lf-qw6jd-6jng2-hqjn", "code": 174450, "reason": "conflict_version_mismatch", "current_version_id": "3jnix-kx7lb-pf2lf-qw6jd-6jng2-hqjn" } ] } } ``` **How to recognise this conflict.** Branch on `params[].reason == "conflict_version_mismatch"` — that is the only field that names the *cause*. If your transport keeps only the four standard entry fields, `params[].name == "if_version_id"` with `params[].kind == "conflict"` tells you a precondition on that parameter failed, which is enough to stop and re-read — but it is **not** equivalent to the reason: another conflict cause on the same parameter would look identical, so do not treat it as proof of a version mismatch. Do **not** branch on the HTTP status — `409` also reports several unrelated conditions — and do **not** branch on the numeric code, which is assigned per call site and therefore differs between endpoints reporting the same cause. `kind` here is `conflict`, which extends the values used for validation failures (`missing`, `invalid`, `type_mismatch`). It is distinct from `invalid` on purpose: `invalid` means the supplied `if_version_id` was not a well-formed id, whereas `conflict` means it was well-formed and the version had moved. Treating them alike would leave you unable to tell a malformed precondition from a stale one. **Rebasing.** Take the current version from `current_version_id`, re-read the note, and re-apply your edit against it. The id is also appended to `message` in the fixed form ` current_version_id=` for clients that surface only the message text. Do **not** resend the same request unchanged — the version has moved and will stay moved, so an unmodified retry cannot succeed. If the current state cannot be resolved at the time of the conflict, `current_version_id` is omitted and the message carries no id suffix; the entry still carries `name`, `kind`, `message`, `code` and `reason`, so the conflict remains identifiable. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Note not found | | `1605 (Invalid Input)` | 406 | Node is not a note | | `1609 (Not Found)` | 404 | Note is in trash | | `1605 (Invalid Input)` | 406 | No content or name provided | | `1605 (Invalid Input)` | 406 | Duplicate name in parent folder | | `113958` | 409 | `if_version_id` did not match the note's current version. `error.params[]` carries the conflict entry — see *Conflict response* above. (`1660` is not the value of `error.code`.) | | `1700 (Forbidden)` | 403 | `realtime-note` token does not match the requested note or workspace | | `1700 (Forbidden)` | 403 | `realtime-note` token lacks edit permission (a view token cannot update) | --- ## Read Note ``` GET /current/workspace/{workspace_id}/storage/{node_id}/readnote/ GET /current/share/{share_id}/storage/{node_id}/readnote/ ``` Read a note's content as JSON. Unlike the binary `/read/` endpoint, this returns the sanitized markdown content as a string within the JSON response along with the full note resource. **Auth required.** Permission: View (workspace), download permission or download token (share). The workspace endpoint also accepts a `realtime-note` bearer token (minted by `GET /current/realtime/note-auth/{profile_id}/{note_id}`) in place of a user JWT, for the collaborative-editing backend to read on the user's behalf. The token is bound to a specific note and workspace and is rejected (`403 Forbidden`) if it does not match the requested note or workspace. The share note endpoint does not accept this token. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | Note OpaqueId | **Query parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `version_id` | string | No | Specific version OpaqueId to read | | `token` | string | No | Download token (share only -- bypasses JWT auth) | **curl example:** ```bash # Workspace curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/readnote/" \ -H "Authorization: Bearer {jwt_token}" # Share curl -X GET "https://api.fast.io/current/share/1234567890123456789/storage/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/readnote/" \ -H "Authorization: Bearer {jwt_token}" # Share with download token (no JWT needed) curl -X GET "https://api.fast.io/current/share/1234567890123456789/storage/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/readnote/?token={download_token}" ``` **Response:** ```json { "result": true, "content": "# Meeting Notes\n\nDiscussed project timeline.", "note": { "id": "2ik5q-a43cm-uixi2-van5r-3eolo-7mue", "type": "note", "name": "meeting-notes.md", "parent": "root", "mimetype": "text/markdown", "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "created": "2025-01-15 10:30:00 UTC", "modified": "2025-01-15 10:30:00 UTC" } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `content` | string | Sanitized markdown content | | `note` | object | Full note node object (same shape as other node responses) | **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Invalid node ID | | `1609 (Not Found)` | 404 | Note not found | | `1605 (Invalid Input)` | 406 | Node is not a note | | `1609 (Not Found)` | 404 | Note is in trash | | `1609 (Not Found)` | 404 | Version not found | | `1605 (Invalid Input)` | 406 | Version does not belong to this note | | `1609 (Not Found)` | 404 | Version data no longer available | | `1680 (Access Denied)` | 401 | No permission to read notes (share only) | | `1680 (Access Denied)` | 401 | No permission to read notes you did not create (share, creator-only restriction) | | `1700 (Forbidden)` | 403 | `realtime-note` token does not match the requested note or workspace | | `1700 (Forbidden)` | 403 | `realtime-note` token lacks read permission | **Notes:** - On shares, a valid download token can be passed via the `token` query parameter to bypass JWT authentication. - Share permissions may restrict note reading to notes the user created (creator-only restrictions). --- ## Update Node ``` POST /current/workspace/{workspace_id}/storage/{node_id}/update/ POST /current/share/{share_id}/storage/{node_id}/update/ ``` Update a node: rename, replace content with a new upload, and/or update custom metadata. Note nodes can only be **renamed** with this endpoint (a name-only update). To change a note's content, title, or short description, use `updatenote` instead — supplying `from`, `metadata_title`, or `metadata_short` on a note (or omitting `name`) is rejected. An optional `if_version_id` parameter adds a compare-and-swap precondition on a content replace or a rename — see *Conflict response* below. **Auth required.** Permission: Guest (workspace), file modification permission (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | Node OpaqueId | **Request body (form-encoded):** | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `name` | string | No | 1-255 characters (counted as characters, not bytes) | New node name | | `from` | string | No | JSON-encoded | New file content source (same format as addfile) | | `metadata_title` | string | No | Max 50 chars | Custom title override | | `metadata_short` | string | No | Max 2048 chars | Custom short description override | | `if_version_id` | string | No | Version OpaqueId | Compare-and-swap precondition — the update proceeds only if the node's current `version` still matches this value; otherwise the request is rejected with `409 Conflict` and no change is made. Applies to a content replace via either `from.type=upload` or `from.type=hash`, and to a rename via `name`. It is refused with `406` on a metadata-only update, which creates no new version (see below). Omit it and behavior is unchanged — last write wins. A present-but-empty value (`if_version_id=`) is rejected as invalid input, not treated as omitted. | At least one field should be provided. `if_version_id` does not itself count toward that requirement — supplying only `if_version_id` (with no `name`, `from`, `metadata_title`, or `metadata_short`) still returns "No update parameters were specified". **curl example (rename):** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/update/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'name=new-document-name.pdf' ``` **curl example (replace content and update metadata):** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/update/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'from={"type":"upload","upload":{"id":"upload_opaque_id"}}' \ -d 'metadata_title=Updated Report' \ -d 'metadata_short=Q1 2025 revision' ``` **Response:** ```json { "result": true, "node": { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "new-document-name.pdf", "parent": "root", "size": 5242880, "version": "3ak5n-dr47a-qnylo-kzv6c-e6bnm-3u3c", "modified": "2025-01-22 11:00:00 UTC" } } ``` **A precondition must have something that can invalidate it.** `if_version_id` is refused with `406` when the request supplies neither `from` nor `name` — a metadata-only update. Custom metadata does not create a new version, so the precondition would still be satisfied for the next writer: two callers holding the same version could both succeed and the second would silently overwrite the first, while both were told a compare-and-swap protected them. Send `if_version_id` together with `name` or `from`, or omit it. Metadata alongside a rename or a content replace is fine — those do create a version, so the precondition is real. **Conflict response (`if_version_id` mismatch):** When `if_version_id` is supplied and does not match the node's current version, the request is rejected with HTTP `409 Conflict` and no new version is created. Two limits are worth knowing. **A `409` guarantees the node was not changed — no new version, no rename, no content swap — but it is not a promise that the request left nothing at all behind:** replacing content by upload may already have stored the uploaded bytes before the precondition was evaluated, and those unreferenced bytes are reclaimed automatically. Replacing by `hash` never has this residue, because it only references content that already exists. **And on a share whose storage is its parent workspace's (a workspace-folder share), replacing by `hash` is not available at all** and answers `404` regardless of `if_version_id`; use an upload there. `error.params` is a **list of parameter entries**, the same shape documented for `updatenote` above: ```json { "error": { "code": 180212, "text": "The file was modified since the supplied version", "params": [ { "name": "if_version_id", "kind": "conflict", "message": "The file was modified since the supplied version. current_version_id=3jnix-kx7lb-pf2lf-qw6jd-6jng2-hqjn", "code": 106523, "reason": "conflict_version_mismatch", "current_version_id": "3jnix-kx7lb-pf2lf-qw6jd-6jng2-hqjn", "contested": true, "rebase_count": 3, "contested_since": "2026-08-26 04:00:00 UTC" } ] } } ``` Shown for the workspace endpoint; the share endpoint returns the identical shape with outer `error.code` `158175` and nested `params[].code` `101301`. Branch on `params[].reason == "conflict_version_mismatch"` — the only field naming the cause — and not on the `409` status (which also reports unrelated conditions) or on the numeric `code` (assigned per call site, so it differs between the workspace and share variants of this same endpoint, and between this endpoint and `updatenote`). See *Conflict response* and *How to recognise this conflict* under Update Note above for the full field-by-field rationale; it applies unchanged here. **Contention (`contested`, `rebase_count`, `contested_since`).** A sliding ~10-minute window of how many writes this node has refused, so a losing writer can tell bad luck from a genuine fight. `rebase_count` counts conflicts inside the window including this one; `contested` is `true` from the second onward; `contested_since` is when the window opened. **`contested: true` means ESCALATE TO A HUMAN — which is not the same as "stop writing", and what it implies depends on what you are.** An **autonomous agent** deciding whether to loop should stop and surface: retrying against a live editor is how one conflict becomes a storm. A **stateful relay** holding a person's live editing session must NOT stop persisting — its "retry" is the mechanism by which that person's unsaved keystrokes reach the platform, so stopping silently drops their work. Escalate by telling the human a conflict is live, and keep holding their content. **These three keys are OMITTED, never zeroed, when the signal is unavailable.** Absent means *unknown*; it does not mean uncontested. Treat a missing block as "no information" and fall back to your normal retry policy, not as permission to retry. **Rebasing.** Take the current version from `current_version_id`, re-read the node (or re-fetch its `version` via `list`/`details`), and re-apply your update against it. Do **not** resend the same request unchanged — the version has moved and will stay moved, so an unmodified retry cannot succeed. **Link rename refusal.** Supplying `if_version_id` together with a new `name` is refused with HTTP `406` when the target node is a **link**, rather than attempted: renaming a link propagates the new name to the link's target *before* the version check runs, so a rejected write could still leave the rename applied. Rename the link without `if_version_id`, or supply `if_version_id` without a new `name`. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found | | `1605 (Invalid Input)` | 406 | Notes can only be renamed here — supply only `name`; use `updatenote` for content, title, or description changes | | `1605 (Invalid Input)` | 406 | No update parameters were specified | | `1605 (Invalid Input)` | 406 | Cannot update a folder or link with file data | | `1605 (Invalid Input)` | 406 | Name conflict — a file or folder with that name already exists in this location | | `1693 (Temporarily Unavailable)` | 503 | Chunk manifest not yet durable when replacing content — retry after a brief delay | | `1609 (Not Found)` | 404 | Node is in trash | | `1680 (Access Denied)` | 401 | No modify permission (share only) | | `1680 (Access Denied)` | 401 | No permission to modify files you did not create (share, creator-only restriction) | | `158175` / `180212` (share / workspace) | 409 | `if_version_id` did not match the node's current version (no change made). `error.params[]` carries the conflict entry — see *Conflict response* above. | | `182375` / `132163` (share / workspace) | 406 | `if_version_id` supplied together with a new `name` on a link node — see *Link rename refusal* above. | **Notes:** - At least one of `name`, `from`, `metadata_title`, or `metadata_short` must be provided. - Replacing content creates a new version. - Renaming a link node propagates the rename to the linked share. - Custom metadata overrides AI-generated summary values for display. - Share permissions may restrict modification to files the user created (creator-only restrictions). - `if_version_id` guards a content replace with compare-and-swap; see *Conflict response* above. It cannot be combined with a new `name` on a link node — see *Link rename refusal* above. --- ## Move Node ``` POST /current/workspace/{workspace_id}/storage/{node_id}/move/ POST /current/share/{share_id}/storage/{node_id}/move/ ``` Move a node to a different folder within the same storage instance. **Auth required.** Permission: Guest (workspace), file modification permission (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | Node OpaqueId to move | **Request body (form-encoded):** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `parent` | string | Yes | Destination folder OpaqueId or `"root"` | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/move/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'parent=2qk7d-kri4y-yievb-q5hri-eq4io-hij5' ``` **Response:** ```json { "result": true } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Source or destination node not found | | `1609 (Not Found)` | 404 | Source or destination is in trash | | `1605 (Invalid Input)` | 406 | Cannot move a folder into itself or its subfolders | | `1680 (Access Denied)` | 401 | No move permission (share only) | | `1680 (Access Denied)` | 401 | No permission to move a folder containing files you cannot modify (recursive move requires modify-all, share only) | **Notes:** - Guests who can view or modify only their own files may copy or move individual files they created; copying or moving a whole folder requires permission to view (copy) or modify (move) all files in the share. - **Conflict resolution:** If a file with the same name exists in the destination, the existing file is replaced (moved to trash for rollback). Folder or type-mismatch conflicts fall back to renaming. --- ## Copy Node ``` POST /current/workspace/{workspace_id}/storage/{node_id}/copy/ POST /current/share/{share_id}/storage/{node_id}/copy/ ``` Copy a node to another folder within the same storage instance. Folder copies are recursive. **Auth required.** Permission: Guest (workspace), file/folder creation permission (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | Node OpaqueId to copy | **Request body (form-encoded):** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `parent` | string | Yes | Destination folder OpaqueId or `"root"` | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/copy/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'parent=2qk7d-kri4y-yievb-q5hri-eq4io-hij5' ``` **Response:** ```json { "result": true, "node": { "id": "2fbt2-66lwc-hle6y-kokf2-wahnc-z4py", "type": "file", "name": "document.pdf", "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5" }, "job": null } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Source or destination not found | | `1605 (Invalid Input)` | 406 | Destination is not a folder | | `1609 (Not Found)` | 404 | Source or destination is in trash | | `1605 (Invalid Input)` | 406 | Cannot copy a folder into itself or a descendant -- the destination parent is the source folder, or a folder beneath it | | `1680 (Access Denied)` | 401 | No permission to view or copy files you did not create (share, creator-only restriction) | | `1680 (Access Denied)` | 401 | No permission to copy a folder containing files you cannot view (recursive copy requires view-all, share only) | **Notes:** - Creates a deep copy for folders (all children are copied recursively). - The copied node gets a new OpaqueId. - Guests who can view only their own files may copy or move individual files they created; copying or moving a whole folder requires permission to view (copy) or modify (move) all files in the share. - **Conflict resolution:** If a file with the same name exists in the destination, the existing file is replaced (moved to trash for rollback). Folder or type-mismatch conflicts fall back to renaming. - **The copy normally inherits the original's metadata.** Copying a file or a note carries its extracted and hand-entered metadata values onto the copy, with their sources and confidence intact — the copy holds the same content, so the same values are true of it, and it is not re-analyzed at your expense. Inheritance happens after the copy itself is saved, and it is **best-effort**: if it cannot complete, or if the original's stored content changed while the copy was being made — so the values would no longer describe the copy's own content — the copy is still created successfully, just with no metadata, and is treated as new content from then on. **Nothing is retried in the background**, so a copy that arrives without metadata keeps none until it is analyzed or filled in again. See *Transfer Node* below for metadata behavior when copying to a different storage instance. --- ## Transfer Node ``` POST /current/workspace/{workspace_id}/storage/{node_id}/transfer/ POST /current/share/{share_id}/storage/{node_id}/transfer/ ``` Copy or move a node to a different storage instance (e.g., from workspace to share, share to workspace, or share to share). By default the original node remains in place (`mode=copy`, the default). Use `mode=move` to copy the node and then trash the source. There is no separate "move" endpoint -- use this transfer endpoint with the `mode` parameter to control the behavior. **Auth required.** Permission: Guest on source + write access on destination. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | Source profile ID | | `{node_id}` | string | Yes | Node OpaqueId to transfer, or `"root"` for all | **Request body (form-encoded):** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `instance` | string | Yes | 19-digit destination workspace or share profile ID | | `parent` | string | Yes | Destination parent folder OpaqueId or `"root"` | | `mode` | string | No | `copy` (default) or `move`. When `move`, the source node is trashed after copying. Cannot use `mode=move` when `{node_id}` is `"root"`. | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/transfer/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'instance=9876543210987654321' \ -d 'parent=root' ``` **Move example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/transfer/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'instance=9876543210987654321' \ -d 'parent=root' \ -d 'mode=move' ``` **Response (copy):** ```json { "result": true, "node": { "id": "2rugc-wuylb-far5j-yxics-lla5z-rmbu", "type": "file", "name": "document.pdf", "parent": "root" }, "job": null } ``` **Response (move):** ```json { "result": true, "node": { "id": "2rugc-wuylb-far5j-yxics-lla5z-rmbu", "type": "file", "name": "document.pdf", "parent": "root" }, "source_trashed": true, "job": null } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Source node not found or outside scope | | `1609 (Not Found)` | 404 | Source or destination instance (workspace/share) not found | | `1680 (Access Denied)` | 401 | No modify permission on the source node (share only) | | `1680 (Access Denied)` | 401 | No permission to modify source files you did not create (share, creator-only restriction) | | `1680 (Access Denied)` | 401 | No permission to transfer a whole folder or root (recursive copy requires view-all, move requires modify-all, share only) | | `1680 (Access Denied)` | 401 | No write access to destination -- the caller lacks file-creation permission on the destination share (e.g. a send-type share where guests cannot create) | | `1680 (Access Denied)` | 401 | Destination is a password-protected public share and a valid share password was not supplied | | `1680 (Access Denied)` | 401 | Writing into a personal (user-owned) share requires an identified caller with creation permission (an unauthenticated caller is denied) | | `1680 (Access Denied)` | 401 | The request token's scope does not cover the destination share (a scoped token must include the destination share, or its parent workspace/org) | | `1605 (Invalid Input)` | 406 | Name conflict at destination, or unsupported/not-allowed transfer | | `1605 (Invalid Input)` | 406 | `mode=move` cannot be used with `"root"` as the source node | | `1605 (Invalid Input)` | 406 | Cannot move/copy a folder into itself or a descendant -- the destination `parent` is the source folder, or a folder beneath it, in the same storage instance | **Notes:** - Folder transfers are recursive. - The user must have write access to both source and destination. Writing into a destination share requires file-creation permission on that share, honoring its type and access settings; a password-protected public destination share also requires a valid share password. - When `mode=move`, the source node is trashed in the source storage instance after the copy completes. The response includes `"source_trashed": true` on success. - Guests who can view or modify only their own files may copy or move individual files they created; copying or moving a whole folder requires permission to view (copy) or modify (move) all files in the share. - **Metadata does not carry over to the destination.** Copying or moving a node to a different storage instance carries no metadata: field definitions belong to a workspace, so a value moved across would be attached to the wrong field. The transfer itself succeeds either way. --- ## Delete Node (Move to Trash) ``` DELETE /current/workspace/{workspace_id}/storage/{node_id}/delete/ DELETE /current/share/{share_id}/storage/{node_id}/delete/ ``` Move a node to trash. Pass `"trash"` as the `{node_id}` to empty the entire trash bin. **Auth required.** Permission: Guest (workspace), file modification permission (share). Emptying trash on shares requires admin permission. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | Node OpaqueId, or `"trash"` to empty the entire trash | **curl example:** ```bash # Delete a specific node curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/delete/" \ -H "Authorization: Bearer {jwt_token}" # Empty the trash curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/storage/trash/delete/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "job": null } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found | | `1609 (Not Found)` | 404 | Node already in trash | | `1680 (Access Denied)` | 401 | No delete permission (share only) | | `1680 (Access Denied)` | 401 | No permission to empty trash (share, non-admin) | | `1693 (Temporarily Unavailable)` | 503 | Emptying the trash only: another trash operation on this workspace or share is in progress; nothing was changed — retry the same request after a brief delay | **Notes:** - Deleting a folder moves it and all children to trash recursively. - Share delete permissions may be restricted to files the user created. - **Trashed files still count toward storage usage.** Moving a node to trash does not reduce the storage your org is billed for — the bytes are still stored, and they are still yours to restore. Only a permanent delete releases them. - **Emptying the trash returns as soon as the trash is empty**, but what it held is not removed straight away: those files are retained for 30 days as a recovery window, and they keep counting toward your storage usage for the whole of that window. Emptying the trash therefore does NOT reduce storage usage immediately — the bytes are released only once the window has passed. The call is still not undoable from the API; the window is a safeguard against an accidental empty, not a second trash. - **A storage limit measured over a billing period does not clear when you free space.** The storage meter records the highest usage seen during the period, so an org that went over its limit keeps returning `402` on writes until the period resets or more credit is added. Emptying the trash is not a remedy for it: the emptied files keep counting for their 30-day retention window, and even once they are released the period's high-water mark stands. --- ## Purge Node (Permanent Delete) ``` DELETE /current/workspace/{workspace_id}/storage/{node_id}/purge/ DELETE /current/share/{share_id}/storage/{node_id}/purge/ ``` Permanently delete a node that is already in trash. **Irreversible.** **Auth required.** Permission: Member (workspace), admin (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | OpaqueId of the trashed node | **curl example:** ```bash curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/purge/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "job": null } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found | | `1605 (Invalid Input)` | 406 | Node is not in trash | | `1680 (Access Denied)` | 401 | Insufficient permission (share, non-admin) | | `1693 (Temporarily Unavailable)` | 503 | Another trash operation on this workspace or share is in progress; nothing was changed — retry the same request after a brief delay | --- ## Restore from Trash ``` POST /current/workspace/{workspace_id}/storage/{node_id}/restore/ POST /current/share/{share_id}/storage/{node_id}/restore/ ``` Restore a trashed node to its original location. **Auth required.** Permission: Guest (workspace), file modification + admin (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | OpaqueId of the trashed node | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/restore/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "job": null } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found | | `1605 (Invalid Input)` | 406 | Node is not in trash | | `1605 (Invalid Input)` | 406 | Node is inside a trashed folder (restore the parent folder instead) | | `1680 (Access Denied)` | 401 | No restore permission (share only) | | `1693 (Temporarily Unavailable)` | 503 | Another trash operation on this workspace or share is in progress; nothing was changed — retry the same request after a brief delay | --- ## List Versions ``` GET /current/workspace/{workspace_id}/storage/{node_id}/versions/ GET /current/share/{share_id}/storage/{node_id}/versions/ ``` List all versions of a file, note, or folder node. **Workspace route:** auth required, View permission on the workspace. **Share route:** auth is optional -- an anonymous caller may read a public-link share (the share password, when set, must still be satisfied). Access is decided entirely by the share's own `permissions.filesystem.file_view` policy; there is no workspace-level requirement. - `file_view: "all"` -- every version of every node in the share is listable. - `file_view: "owned"` -- only versions of nodes the caller created are listable; every other node is rejected. An anonymous caller has no creator identity, so all nodes are rejected. - `file_view: "none"` -- the endpoint is rejected outright. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | Node OpaqueId | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/versions/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** `versions` is an object carrying a `count` and an `items` array -- not a bare array. ```json { "result": true, "versions": { "count": 2, "items": [ { "id": "3maag-qdkzs-whhrp-5jifb-zgbni-2udf", "type": "file", "current_version": true, "nodeId": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "name": "document.pdf", "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "created": "2025-01-20 14:45:00 UTC", "size": 5242880, "hash": "abc123def456789...", "hash_algo": "sha256", "mimetype": "application/pdf", "mimecategory": "document", "previews": { "thumbnail": { "state": "ready" }, "pdf": { "state": "ready" } }, "virus": { "status": "scanned", "infected": false }, "ai": { "state": "indexed", "attach": true, "summary": true }, "file_attributes": {}, "summary": { "title": "Quarterly Report", "short": "Q4 financial summary", "long": "..." }, "origin": { "type": "User", "creator": "9876543210987654321", "operations": ["modify"], "created": "2025-01-20 14:45:00 UTC" }, "replaces": { "status": "known", "version_id": "34wse-ehjvl-zefmd-2dfeh-hhw66-t4xe" }, "author": { "status": "known", "user_id": "9876543210987654321", "actor_type": "User", "agent_name": "Claude-2", "agent_name_source": "api_key_label" } }, { "id": "34wse-ehjvl-zefmd-2dfeh-hhw66-t4xe", "type": "file", "current_version": false, "nodeId": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "name": "document.pdf", "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "created": "2025-01-15 10:30:00 UTC", "size": 4194304, "hash": "def456abc789012...", "hash_algo": "sha256", "mimetype": "application/pdf", "mimecategory": "document", "previews": { "thumbnail": { "state": "ready" }, "pdf": { "state": "ready" } }, "virus": { "status": "scanned", "infected": false }, "ai": { "state": "indexed", "attach": true, "summary": false }, "file_attributes": {}, "summary": null, "origin": { "type": "User", "creator": "9876543210987654321", "operations": ["create"], "created": "2025-01-15 10:30:00 UTC" }, "replaces": { "status": "none", "version_id": null }, "author": { "status": "known", "user_id": "9876543210987654321", "actor_type": "User", "agent_name": null, "agent_name_source": null } } ] } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `versions.count` | integer | Number of entries in `versions.items` | | `versions.items` | array | The version entries, described below | **Per version entry:** | Field | Type | Description | |-------|------|-------------| | `id` | string | Version OpaqueId | | `type` | string | Node type: `"file"`, `"folder"`, or `"note"` | | `current_version` | boolean | `true` if this is the node's current (live) version | | `nodeId` | string | OpaqueId of the node this version belongs to | | `name` | string | Node name at this version | | `parent` | string | Parent folder OpaqueId, or `"root"` / `"trash"` | | `created` | string | Version creation timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `deleted` | string | Present only on a version created by trashing (`YYYY-MM-DD HH:MM:SS UTC`) | | `deleted_from` | string/null | OpaqueId of the folder the node was trashed from; present alongside `deleted` | | `size` | integer | File size in bytes (file/note versions) | | `hash` | string | Content hash for this version (file/note versions) | | `hash_algo` | string | Hash algorithm, e.g. `"sha256"` (file/note versions) | | `mimetype` | string | MIME type (file/note versions) | | `mimecategory` | string | MIME category (file/note versions) | | `previews` | object | Preview state keyed by preview type, each `{ "state": "..." }` (file/note versions) | | `virus` | object | Virus scan result: `status` (`"scanned"`, `"unscanned"`, `"unknown"`), plus `infected` and/or `reason` (file/note versions) | | `ai` | object | AI processing state: `state` (`"disabled"`, `"pending"`, `"in_progress"`, `"ready"`, `"indexed"`, `"failed"`), `attach` (boolean), `summary` (boolean) (file/note versions) | | `file_attributes` | object | Metadata read out of the file itself: `media_metadata` and/or `exif_metadata` when available, otherwise empty (file/note versions). Returned only to callers permitted to download the file -- a view-only caller gets `{}` (see *Embedded File Metadata* under Node Object Schema) | | `summary` | object/null | AI summary `{ title, short, long }`, or `null` when none (file/note versions) | | `origin` | object | Provenance for this version -- see below | | `replaces` | object | Which version this version replaced -- see below | | `author` | object | Who created this version -- see below | **`origin` object:** | Field | Type | Description | |-------|------|-------------| | `origin.type` | string | Identifier type of the actor that produced the version (e.g. `"User"`), or `"unknown"` for versions that predate origin tracking | | `origin.creator` | string | Creator profile ID, `"anonymous"` for an anonymous public-link contributor, or `"unknown"` | | `origin.operations` | array | Operations that produced this version: any of `"create"`, `"rename"`, `"move"`, `"modify"`, `"restore"` | | `origin.created` | string/null | When the origin record was written (`YYYY-MM-DD HH:MM:SS UTC`), or `null` for versions that predate origin tracking | **`replaces` object:** | Field | Type | Description | |-------|------|-------------| | `replaces.status` | string | `"known"`, `"none"`, or `"unknown"` | | `replaces.version_id` | string/null | The id of the version this one replaced; non-null only when `status` is `"known"` | - `known` -- `version_id` is the version this version replaced. - `none` -- this version replaced nothing; it is the version the file was created with. - `unknown` -- it cannot be determined; `version_id` is `null`. **Clients must branch on `status` and must never treat a null `version_id` as "this was the first version"** -- `none` and `unknown` are different answers. - Replacement lineage is derived from the order of the versions that still exist, not stored. Older version history is thinned over time, so for versions older than roughly half a day the preceding surviving version is not necessarily the one that was replaced -- those report `unknown` rather than naming a version that might be wrong. The **current** version's `replaces` is always resolvable, at any age. - `unknown` is also returned when the returned list is not the file's complete history -- a full page of results (older versions exist beyond it), or a listing that omits versions the caller may not access (the File Share version listing omits versions whose content is unavailable). **`author` object:** | Field | Type | Description | |-------|------|-------------| | `author.status` | string | `"known"` or `"unknown"` | | `author.user_id` | string/null | Profile id credited with creating this version; `"anonymous"` for an anonymous public-link contributor; `null` when unknown | | `author.actor_type` | string/null | What kind of profile `user_id` is -- usually `"User"`, but not always (see below); `null` when unknown | | `author.agent_name` | string/null | Name of the agent that acted on that account's behalf, when one did | | `author.agent_name_source` | string/null | Where that name came from; `null` when no agent acted | - **`user_id` is a PROFILE id and it is not always a person.** Check `actor_type` before resolving it against a user lookup: some versions are attributed to a workspace rather than to a user, and looking one of those up as a user will find nothing. `author.user_id` / `author.actor_type` carry the same pair as `origin.creator` / `origin.type`. - The account is who the version is attributed to; the agent name only qualifies it -- there is always an account behind an agent. - **`agent_name` is self-declared, not verified.** Display it; never rely on it to identify or authorize anyone. - `status` is `unknown` in two different situations that `user_id` alone cannot tell apart. Most history does carry an author, so `unknown` is not the common case -- but it is a value you will genuinely receive, and it never fills in later: - **Authorship was never recorded** -- versions created before authorship tracking existed. These also return an empty `origin.operations` and a `null` `origin.created`, which is how you distinguish this case. - **Nothing was acting as an account** -- versions created by background processing rather than by a request: a synced file that changed at the connected cloud provider, a folder copy large enough to run in the background, or an upload assembled without an associated account. Render both as "unknown", never as an empty or missing author. Folder versions carry only the basic fields (`id`, `type`, `current_version`, `nodeId`, `name`, `parent`, `created`, `replaces`, `author`) plus `origin`; the file-specific fields above are omitted. A version that was created by trashing carries additional `deleted` / `deleted_from` fields. **Error responses:** the Error Code column shows the application error class followed by the per-call-site value returned in `error.code`. | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` -- `193615` / `124906` | 404 | Node not found (share / workspace) | | `1609 (Not Found)` -- `173577` | 404 | Node exists but falls outside the share's folder scope (share only) | | `1609 (Not Found)` -- `148373` | 404 | The workspace folder backing the share was deleted (share only) | | `1605 (Invalid Input)` -- `194361` / `149251` | 406 | Unsupported node type, for example a link (share / workspace) | | `1665 (Object Init Failed)` -- `167497` / `112869` | 500 | Node data is corrupted (share / workspace) | | `1664 (Datastore Error)` -- `186805` / `137723` | 500 | Version lookup failed (share / workspace) | | `1680 (Access Denied)` -- `144499` | 401 | Share `file_view` is `none` (share only) | | `1680 (Access Denied)` -- `134467` | 401 | No file-view access to this node (share only) | | `1680 (Access Denied)` -- `120944` | 401 | Share `file_view` is `owned` and the caller did not create this node (share only) | **Notes:** - Share permissions may restrict version listing to files the user created (`file_view: "owned"`). - The share route needs no workspace permission and no authenticated user; a public-link share is readable anonymously. --- ## Restore Version ``` POST /current/workspace/{workspace_id}/storage/{node_id}/restore-version/ POST /current/share/{share_id}/storage/{node_id}/restore-version/ ``` Restore a file to a previous version. Creates a new version pointing to the historical version's content. Both filename and content are restored. **Auth required.** Permission: Guest (workspace), file modification permission (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | File OpaqueId | **Request body (form-encoded):** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `version_id` | string | Yes | OpaqueId of the version to restore | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/restore-version/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'version_id=34wse-ehjvl-zefmd-2dfeh-hhw66-t4xe' ``` **Response:** ```json { "result": true, "new_version": { "id": "3qced-56d4r-4o7q3-w3xru-zw3m5-nutj", "type": "file", "current_version": true, "nodeId": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "name": "original-name.pdf", "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "created": "2025-01-25 09:00:00 UTC", "size": 4194304, "hash": "def456abc789012...", "hash_algo": "sha256", "mimetype": "application/pdf", "mimecategory": "document", "previews": { "thumbnail": { "state": "ready" }, "pdf": { "state": "ready" } }, "virus": { "status": "scanned", "infected": false }, "ai": { "state": "indexed", "attach": true, "summary": false }, "file_attributes": {}, "summary": null, "origin": { "type": "User", "creator": "9876543210987654321", "operations": ["restore"], "created": "2025-01-25 09:00:00 UTC" }, "replaces": { "status": "unknown", "version_id": null }, "author": { "status": "known", "user_id": "9876543210987654321", "actor_type": "User", "agent_name": null, "agent_name_source": null } }, "node": { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "original-name.pdf", "version": "3qpwy-4fuxn-o5zkk-qqqh5-w6zqd-habx" } } ``` `new_version` carries the same per-version fields as a [List Versions](#list-versions) entry (it is the freshly created current version); `node` is the standard node resource (see *Node Object Schema*). **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found | | `1605 (Invalid Input)` | 406 | Can only restore file versions (not folders) | | `1605 (Invalid Input)` | 406 | Cannot restore version of trashed file | | `1609 (Not Found)` | 404 | Version not found | | `1605 (Invalid Input)` | 406 | Version does not belong to this file | | `1609 (Not Found)` | 404 | Version data no longer available | | `1680 (Access Denied)` | 401 | No permission to restore versions (share only) | **Notes:** - Original versions are preserved; restoring creates a new current version whose content and name match the selected historical version. - Both filename and content are restored to the historical version's state. - `new_version.replaces` is always `"unknown"` on this endpoint -- this response returns a single version with no surrounding history to derive lineage from. Call [List Versions](#list-versions) for lineage. --- ## Download File (Read) ``` GET /current/workspace/{workspace_id}/storage/{node_id}/read/ GET /current/share/{share_id}/storage/{node_id}/read/ ``` Download file content as binary. For notes, returns raw markdown. Supports byte-range requests for partial downloads and video streaming. **Auth: JWT or download token.** Permission: View (workspace), download permission (share). With a valid `token` query parameter, no JWT is required. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | File or note OpaqueId | **Query parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `token` | string | No | Download token from `requestread` (bypasses JWT auth) | | `version_id` | string | No | Specific version OpaqueId to download | **curl examples:** ```bash # Download with JWT auth curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/read/" \ -H "Authorization: Bearer {jwt_token}" \ -o output.pdf # Download with token (no JWT needed) curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/read/?token={download_token}" \ -o output.pdf # Download specific version curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/read/?version_id=34wse-ehjvl-zefmd-2dfeh-hhw66-t4xe" \ -H "Authorization: Bearer {jwt_token}" \ -o output_v1.pdf # Byte-range request (streaming) curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/read/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Range: bytes=0-1023" ``` **Response:** Binary file content streamed directly. - Status `200 OK` for full file, `206 Partial Content` for range requests. - Headers: `Content-Type`, `Content-Length`, `Content-Disposition`, `Accept-Ranges: bytes`. - On a `206`, `Content-Length` is the length of the **returned slice**; the total size is the value after the slash in `Content-Range: bytes {first}-{last}/{total}`. - A syntactically valid range that cannot be satisfied — a first byte at or past the end of the file — returns `416 Range Not Satisfiable` with `Content-Range: */{total}`. An unparseable `Range` header is ignored and the full file is returned with `200`. - Only `GET` is accepted; `HEAD` returns `405`. To learn a file's size without downloading it, send `Range: bytes=0-0` and read the total from `Content-Range`. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | File not found | | `1605 (Invalid Input)` | 406 | Can only read file or note (not folder) | | `1609 (Not Found)` | 404 | File is in trash | | `1609 (Not Found)` | 404 | Version not found | | `1605 (Invalid Input)` | 406 | Version does not belong to this file | | `1609 (Not Found)` | 404 | Version data no longer available | | `1680 (Access Denied)` | 401 | File flagged as virus-infected (share only) | --- ## Request Download Token ``` GET /current/workspace/{workspace_id}/storage/{node_id}/requestread/ GET /current/share/{share_id}/storage/{node_id}/requestread/ ``` Generate a temporary auth-free download token. **Auth required.** Permission: View (workspace), download permission (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | File or note OpaqueId | **Query parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `version_id` | string | No | Pin the token to one specific version. Omit it and the token is bound to the node only — the read call can still name its own `version_id`, and the bytes returned are whatever that read resolves (current, or the version it names). | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/requestread/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` ### Pinning a token to a version Pass `version_id` and the issued token is bound to that exact version. The response then echoes the version it was pinned to: ```json { "result": true, "token": "{download_token}", "version_id": "3o2ex-os4uz-32bkm-icr5l-uv3jd-546r" } ``` **Why pin.** Without it, the token authorises the node and the `version_id` on the *read* selects the bytes — the two are never compared, so a token issued while looking at one version can fetch a different one that was committed in between. **A pinned token cannot**: a read naming any other version is rejected. Use this whenever the bytes you read will become the basis of a later write, so the version you record is provably the version you received. Pass the SAME `version_id` on the subsequent `read` call. A pinned token is rejected on a read that names a different version, and on a read that names none. **Compatibility.** `version_id` is optional and additive — omit it and behaviour is exactly as before. Tokens already issued without it keep working, including on reads that name a version. **Applies to the note read too.** `GET /current/share/{share_id}/storage/{node_id}/readnote/` accepts the same token and the same `version_id`, and enforces the same pin. **Errors at mint time**, rather than later at fetch time: an unknown version returns `404`; a version belonging to a different file returns `406`; a version whose content is no longer stored returns `404`. A backend failure returns a `5xx` and is safe to retry — it is deliberately NOT reported as a missing version, which would tell you the resource is gone when it is not. **Usage:** Append `?token={token}` to the `read` endpoint to download without an Authorization header. Useful for opening files in browser tabs. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | File not found | | `1605 (Invalid Input)` | 406 | Can only read file or note | | `1609 (Not Found)` | 404 | File is in trash | | `182855` (workspace) / `125883` (share) | 404 | Version not found | | `141154` (workspace) / `105838` (share) | 406 | Version does not belong to this file | | `164665` (workspace) / `191654` (share) | 404 | Version data no longer available | | `129880` (workspace) / `190664` (share) | 5xx | Unable to verify version (backend failure, safe to retry) | | `1680 (Access Denied)` | 401 | No download permission (share only) | --- ## Download Folder as ZIP ``` GET /current/workspace/{workspace_id}/storage/{folder_id}/zip/ GET /current/share/{share_id}/storage/{folder_id}/zip/ ``` Download an entire folder as a streaming ZIP archive. **Auth required.** Permission: View (workspace), download permission (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{folder_id}` | string | Yes | Folder OpaqueId, or `"root"` for entire storage | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/root/zip/" \ -H "Authorization: Bearer {jwt_token}" \ -o workspace.zip ``` **Response:** Binary ZIP archive streamed directly with `Content-Type` and `Content-Disposition` headers. **Notes:** - Uses ZIP64 format — supports archives up to 50GB (plan-dependent limits may be lower). - Compatible with all modern extraction tools (WinRAR, 7-Zip, macOS Archive Utility, Windows Explorer). - The archive is streamed; it is not buffered in memory. - Files are stored without compression for immediate streaming. - Maximum 10,000 files per archive. - Rate limited more aggressively than other endpoints due to resource cost. --- ## Recent Files ``` GET /current/workspace/{workspace_id}/storage/recent/ GET /current/share/{share_id}/storage/recent/ ``` List recently modified nodes across all folders, sorted by `updated` descending. Unlike `list` which is scoped to a single folder, this endpoint returns nodes from the entire storage tree. **Auth required.** Permission: View (workspace), Guest+ (share). Public shares may allow password-only access. **Query parameters:** | Parameter | Type | Default | Description | |-----------|--------|---------|------------------------------------------------------| | `page_size` | int | `100` | One of: `100`, `250`, `500` (snapped to nearest) | | `cursor` | string | -- | Opaque cursor string from previous response | | `type` | string | -- | Filter by node type: `file`, `folder`, `link`, `note` | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/recent/?type=file&page_size=250" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "nodes": { "count": 3, "items": [ { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "name": "report.pdf", "type": "file", "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "size": 2048576, "modified": "2025-02-18 14:30:00 UTC", "created": "2025-02-17 10:00:00 UTC" } ] }, "pagination": { "has_more": true, "next_cursor": "eyJsYXN0X3VwZGF0ZWQiOiIyMDI1LTAyLTE4...", "page_size": 100 } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `nodes.count` | integer | Number of nodes in this page | | `nodes.items` | array | Array of node resources | | `pagination.has_more` | boolean | Whether more pages exist | | `pagination.next_cursor` | string/null | Cursor for the next page | | `pagination.page_size` | integer | Effective page size used | **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Invalid pagination cursor | | `1680 (Access Denied)` | 401 | Insufficient permissions to view files (share only) | | `1609 (Not Found)` | 404 | Orphaned workspace folder share | **Notes:** - Sort order is always `updated DESC` and is not configurable. - Uses cursor-based (keyset) pagination, same as the `list` endpoint. - For workspace folder shares, results are post-filtered to the share's subtree. --- ## Search ``` GET /current/workspace/{workspace_id}/storage/search/ GET /current/share/{share_id}/storage/search/ ``` Search files by filename, by content, or by both. By default a query matches the filename **and** the content together and blends the two into one ranked list — which is what you want when you are looking for a topic. When you are looking for a *file*, ask for the filename side explicitly with `search_in=filename` and pick a matching style with `name_match` (`exact`, `prefix`, `contains`, or shell-style `glob`). See *Search modes* below. **Auth required.** Permission: View (workspace), search + file view permissions (share). **Query parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|----------------------------| | `search` | string | Yes | - | Search query string. Under a precise `name_match` this string **is** the pattern. | | `search_in` | string | No | `both` | Which side of the file to match: `filename`, `content`, or `both`. See *Search modes*. | | `name_match` | string | No | `auto` | How the filename is matched: `auto`, `exact`, `prefix`, `contains`, or `glob`. Ignored when `search_in=content`. | | `case_sensitive` | string | No | `false` | `true` / `false` / `1` / `0`. Applies to the precise `name_match` values; ignored under `auto`. | | `files_scope` | string | No | - | Comma-separated `nodeId:versionId` pairs, **query string only**. **Narrows the meaning-based (semantic) leg ONLY** — filename and summary matches are not restricted by it. Takes nodes of `type: "file"` **or** `type: "note"`; a folder or link is refused. See *Scoping to files or folders*. | | `folders_scope` | string | No | - | Comma-separated `nodeId:depth` pairs, **query string only**. **Narrows the meaning-based (semantic) leg ONLY**, same as `files_scope`. Takes nodes of `type: "folder"` only; a file, note or link is refused. **The `:depth` is REQUIRED**; a bare node id is refused with `406` / `113920` *"Invalid folder format. Expected nodeId:depth"*. See *Scoping to files or folders*. Folder **node ids only** — the `root` and `trash` folder aliases are refused; omit the scope entirely to search everything. A scope carries at most 100 references in total, counting expanded subfolders; a tree that runs past that is truncated and the response says so via `search_metadata.scope_incomplete`. | | `filters` | string (JSON) | No | - | **Workspace routes only.** A JSON array of metadata predicate objects. Narrows the search to files whose extracted metadata satisfies **every** predicate, *before* the query runs. Cannot currently be combined with `folders_scope`. See *Filtering by metadata*. | | `details` | string | No | - | `"true"` to include the full node resource for each result (default limit drops to 10) | | `limit` / `offset` | integer | No | - | Offset pagination. In hybrid mode the two retrieval legs are fetched to a fixed depth that does not change with the page, so every page is a slice of one ranking and paging forward never re-orders what you already saw. | | `output` | string | No | `full` | Verbosity: `terse`, `standard`, or `full` (default). Trims `content_snippet` and `best_chunk.text` to a byte budget, and drops `summary_short`, when intelligence is enabled. See *Verbosity* below. | `search_in`, `name_match`, and `case_sensitive` are all optional and all **additive**: omit them and you get exactly the behavior this endpoint has always had — same query, same ranking, same response keys. Values are matched case-insensitively (`GLOB` and `glob` are the same value). **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=quarterly+report" \ -H "Authorization: Bearer {jwt_token}" ``` ### Search modes **`search_in` — which side of the file to match** | Value | Matches | |-------|---------| | `filename` | The file's **name** only. Predictable and pattern-driven — this is the `find`-style surface, and the one to reach for when you know (or can describe) what the file is called. It never runs a content lookup, so it behaves identically whether or not AI features are enabled. | | `content` | The AI's **understanding** of the file — its AI-generated summary plus meaning-based (semantic) matches on what the file is about. | | `both` | *(default)* Filename and content together, blended and ranked — the behavior that shipped before these parameters existed. | ⚠️ **`content` is not a text scan of the file.** Fastio does not index the literal bytes of your documents, so `search_in=content` is **not** `grep` and not full-text search — it matches the AI-generated summary of a file and the file's semantic (meaning-based) index. Consequences worth planning around: - **`content` matches two channels, and only one of them needs intelligence on.** The two are the file's AI-generated **summary** (matched by keyword) and the file's **semantic** (meaning-based) index. Instance intelligence gates the *semantic* half only: turning AI features off stops new meaning-based matching, but it does **not** un-index summaries that were already written. A `content` search on a workspace with AI features off therefore still returns hits for files whose summaries were indexed earlier, and returns nothing when no such summaries exist. - Either way, a file has to have been indexed at some point (`ai.state: indexed`). A file uploaded a moment ago is findable by **name** immediately and by **content** only once indexing completes. - A phrase that appears verbatim inside a document will not necessarily match, and a phrase that never appears in it may match. Ask `content` questions in terms of what a document is *about*, not in terms of an exact string you expect to find. - If you need an exact string, that string is almost always in the **filename** — use `search_in=filename`. **`name_match` — how the filename is matched** Only meaningful when the filename participates in the query (`search_in=filename` or `both`); ignored under `search_in=content`. | Value | Matches | Example | |-------|---------|---------| | `auto` | *(default)* Layered relevance — phrase, prefix, fuzzy, stemmed, and substring tiers, with near-exact filename matches promoted to the top. Unchanged behavior. | `quarterly report` finds `Q4 Quarterly Report (final).pdf` | | `exact` | The whole filename equals the query. Also matches the **extensionless base**, so you can type the name without knowing the extension. | `Q4 Report` matches `Q4 Report.pdf` and `Q4 Report` | | `prefix` | The filename **starts with** the query, taken literally. | `Invoice-` matches `Invoice-2026-0042.pdf` | | `contains` | The filename **contains** the query as a literal substring, anywhere. | `2026` matches `Invoice-2026-0042.pdf` | | `glob` | Shell-style pattern over the **whole** filename: `*` matches any run of characters (including none), `?` matches exactly one. | `Quarterly*.pdf` matches `Quarterly Report.pdf` | **`glob` is the `find`-style surface, and it spans spaces and hyphens.** Because the pattern runs against the complete filename rather than word-by-word, `Quarterly*.pdf` finds `Quarterly Report.pdf`, `report-*.xlsx` finds `report-2026-q1.xlsx`, and `Q?-2026.csv` finds `Q1-2026.csv`. `*.pdf` gives you every PDF. This is the reason `glob` exists — it is the only mode that can match across a space or a hyphen in a filename. **Do not escape your query — escaping is handled for you.** Under `exact`, `prefix`, and `contains`, `*` and `?` are matched **literally**: `name_match=contains` with `search=report*` looks for a filename that really contains the two characters `report*` — it will not match `report-2026.pdf`. That is what makes a filename containing an asterisk searchable at all (`name_match=prefix` with `search=weird*` finds `weird*name.txt`). Only `glob` treats `*` and `?` as wildcards. A client that pre-escapes its query (`report\*`) will search for the backslash. **Case sensitivity.** `case_sensitive` defaults to `false`, which is what an agent almost always wants (it matches `find -iname`). Case-insensitive matching folds **non-ASCII letters too** — `ÄNDERUNG` matches `änderung.docx` and `RÉSUMÉ` matches `résumé.pdf`. It folds case only and does **not** strip accents, so `resume` will not match `résumé.pdf`. Send the query exactly as the user typed it: do **not** lowercase or otherwise normalize it client-side. **Pattern rules (precise modes only).** Under any `name_match` other than `auto`: the query must be non-empty after trimming, and at most **256 characters**; and a `glob` pattern made up of nothing but `*` and `?` is rejected because it matches every file. Violations return `1605 (Invalid Input)`. `auto` keeps the length rules it has always had — `search` is not capped on this endpoint. **Examples:** ```bash # Every PDF in the workspace, by name only — no content lookup at all curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=*.pdf&search_in=filename&name_match=glob" \ -H "Authorization: Bearer {jwt_token}" # A filename that spans a space curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=Quarterly*.pdf&search_in=filename&name_match=glob" \ -H "Authorization: Bearer {jwt_token}" # Everything whose name starts with a known prefix, case-sensitively curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=Invoice-&search_in=filename&name_match=prefix&case_sensitive=true" \ -H "Authorization: Bearer {jwt_token}" # Ask what a document is about, ignoring filenames entirely curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=revenue+guidance+for+next+year&search_in=content" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (keyword-only, intelligence disabled):** ```json { "result": true, "files": { "2ltsuq4mjacuv7pgc5ydlxnsjwee4": { "name": "Q4 Report.pdf", "parent_id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "path": "Finance/Reports", "ancestors": [ { "id": "2e2f3-inlkh-p63sd-3ungd-vfyvc-gilh", "name": "Finance" }, { "id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "name": "Reports" } ], "path_complete": true, "type": "file", "summary_short": null, "best_chunk": null, "metadata_match": false, "metadata_match_field": null, "text_indexed": true } } } ``` **Response (hybrid, intelligence enabled):** ```json { "result": true, "files": { "2emafexxpwthkzj5rlymocyohiufa": { "name": "MSA - Northwind Traders.pdf", "parent_id": "27ifb-ytevm-pzori-ybzul-dro2u-dqcm", "path": "Legal/Contracts", "ancestors": [ { "id": "2tyji-byjgb-3f72a-cpvhd-5jw2i-6um7", "name": "Legal" }, { "id": "27ifb-ytevm-pzori-ybzul-dro2u-dqcm", "name": "Contracts" } ], "path_complete": true, "type": "file", "relevance_score": 0.98, "raw_score": 4.31, "score_source": "metadata", "content_snippet": "This Master Services Agreement is entered into by Northwind Traders …", "match_source": "keyword", "media_segment": null, "mimetype": "application/pdf", "page": { "start_page": 1, "end_page": 1 }, "text_indexed": true, "summary_short": "Master services agreement with Northwind Traders, effective 2026-01-01.", "best_chunk": { "text": "This Master Services Agreement is entered into by Northwind Traders …", "same_as_snippet": false, "page": { "start_page": 1, "end_page": 1 }, "media_segment": null, "score": 6.42, "result_type": "doc", "position": 0, "sequence": 2001, "indexed_version_id": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5" }, "metadata_match": true, "metadata_match_field": null }, "2ltsuq4mjacuv7pgc5ydlxnsjwee4": { "name": "Q4 Report.pdf", "parent_id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "path": "Finance/Reports", "ancestors": [ { "id": "2e2f3-inlkh-p63sd-3ungd-vfyvc-gilh", "name": "Finance" }, { "id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "name": "Reports" } ], "path_complete": true, "type": "file", "relevance_score": 1.0, "raw_score": 0.87, "score_source": "semantic", "content_snippet": "Quarterly revenue grew 18% year-over-year …", "match_source": "both", "media_segment": null, "mimetype": "application/pdf", "page": { "start_page": 3, "end_page": 3 }, "text_indexed": true, "summary_short": "Q4 revenue and margin review for the North America segment.", "best_chunk": { "text": "Quarterly revenue grew 18% year-over-year …", "same_as_snippet": false, "page": { "start_page": 3, "end_page": 3 }, "media_segment": null, "score": 0.87, "result_type": "doc", "position": 12, "sequence": 3001, "indexed_version_id": "3mcys-2dr56-rmgdt-nh36b-prmp7-b47q" }, "metadata_match": false, "metadata_match_field": null } }, "search_metadata": { "intelligence_enabled": true, "semantic_available": true, "scoped": false } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `files` | object | Map of node OpaqueIds to file info. A matching node appears **exactly once**, whichever part of the search found it. Keys are the **unformatted** OpaqueId — the 29-character form with no hyphens — so compare them hyphen-insensitively if you hold ids in the grouped display form. | | `files.{id}.name` | string | File name | | `files.{id}.parent_id` | string\|null | Parent folder node ID, **as it is right now**. The server reads the file's own record on every request and reports the folder it finds there, so this is never the stale value a lagging search index may hold, and `details=true` is not needed for it. A hit that matched only by meaning (`match_source: "semantic"`) arrives from the content engine without one and is filled the same way. It is empty (`""` or `null`) only when that lookup failed, and `path_complete` is `false` on the same row whenever it did. | | `files.{id}.path` | string\|null | **Where the file lives** — the names of the folders from the workspace or share root down to the folder holding this hit, joined with `/`, with no leading or trailing slash (`"Finance/Invoices/AR"`). The file's own name is **not** in it; that is `name`. `""` means the hit sits directly in the root. **`null` whenever `path_complete` is `false`** — read that flag, not this field, to tell "at the root" from "not known". Present on every result row, in both hybrid and keyword-only mode and at every `output` tier. ⚠️ **Display value, not an address.** Folder names are returned exactly as stored and the `/` separator is **not escaped**, so a folder whose own name contains `/` is indistinguishable from a level boundary. Use `ancestors` or `parent_id` to address a folder. | | `files.{id}.ancestors` | array | The same folders as `path`, in the same order, as `{id, name}` objects — `id` being the folder's OpaqueId in the **formatted** (hyphenated) form the rest of the API takes, so each entry is directly usable with `/storage/{parent_id}/list/`. Whenever `path_complete` is `true`, `path` is exactly these names joined with `/`, so the two are one answer in two spellings; `[]` then means the hit is at the root. When `path_complete` is `false` this is a **best-effort suffix**: the folders NEAREST the file, with an unknown number of levels missing from the **top** — never a prefix, and never padded or guessed. It is `[]` when nothing could be resolved at all. | | `files.{id}.path_complete` | boolean | Whether the walk from the file up to the root actually reached it. `true` means `path` and `ancestors` are the complete answer. `false` means the walk stopped early — a folder could not be read, the chain was longer than the server walks, or the response needed more folder lookups than one request will make — in which case `path` is `null` and `ancestors` holds only the partial suffix above. **Every hit is resolved from the file's CURRENT record, never from the `parent_id` the row carried** — a search index lags the tree, so a file moved a moment ago still names its old folder there, and a path walked from it would be wrong while claiming to be complete. That recovery is ATTEMPTED on every row, so a `match_source: "semantic"` hit resolves the same as any other with or without `details=true`; when the record itself cannot be read, the row reports `path_complete: false` like any other unresolved one. `parent_id` is corrected to the live folder when the two disagree AND the walk reached the root, so a corrected value always matches the `path` beside it. On a row whose walk stopped short, `parent_id` is left exactly as the index had it rather than replaced with a folder the platform could not place. **A `false` here is never a statement about where the file is**; a file at the root reports `path_complete: true` with `path: ""`. | | `files.{id}.type` | string | Node type | | `files.{id}.relevance_score` | float | A **rank-fusion** score combining the two retrieval legs — the name/text search and the content search. Each leg contributes by the file's **position** in that leg, a file found by both legs collects both contributions, and the result is normalised **within this result set** so the **maximum `relevance_score` in the set is exactly `1.0`** and every hit is above `0.0`. That is the maximum, **not necessarily the first result** — ordering is tier-first (see *Result ordering*), so a promoted hit with a lower score can come back above it. Its scale is therefore re-derived for every query, and `1.0` means "the best of these results", not "a good result". **Use it to order results — do not threshold it, and do not compare it across queries.** Hybrid mode only. | | `files.{id}.raw_score` | float\|null | The **un-rescaled** retrieval score, on the scale named by `score_source` (on a `metadata` row that is the **keyword** scale — see below). The merge does not divide, clamp or round it against the other hits that came back with it. **That is a statement about rescaling, not a promise the number is constant:** re-running retrieval can still return a different value, because a `keyword` score is BM25 and moves with the index statistics. Not bounded to 0.0–1.0. `null` when `score_source` is `filename`. Hybrid mode only. | | `files.{id}.score_source` | string | An **attribution** — and only three of its four values name a scale. `keyword` (the keyword engine's BM25 score — **unbounded above**, and dependent on the index contents and the query terms), `semantic` (the content engine's similarity score for the passage that ranked the file — the same passage that produced `content_snippet`, `page`, and `media_segment`) and `filename` (the row was placed by a **name match**, not by a measured score, so `raw_score` is `null`) each name **the scale `raw_score` is on**. On a file both legs found, the leg reported is the one that ranked it **higher**; on an equal position the keyword leg is reported, because its score is always a real measurement while a content score is optional and can come back as `0.0`. `metadata` is **not a fourth scale**: it says the row was **promoted** because the query matched its extracted metadata, and its `raw_score` is still populated and **remains on the `keyword` scale** — compare it with other `keyword` rows, not with `semantic` ones. A promoted hit ranked higher by the content engine reports `semantic` rather than `metadata`; read `metadata_match` for the promotion itself. There is **no `both`** — which legs matched is a separate question, answered by `match_source`. Decided **per hit**, so one response can carry all four: **group by `score_source` before comparing any two `raw_score` values.** Hybrid mode only. | | `files.{id}.content_snippet` | string\|null | Matching text from the highest-scoring passage. On a `semantic` or `both` hit that is the passage the content search ranked the file by. **A `keyword`-only hit now carries one too**: the search goes back over the file's indexed text and quotes the best-matching passage, for the **top few results of the page only** and only where `text_indexed` is `true` — so a filename or metadata match is no longer returned without the text behind it. Still `null` on a `semantic` or `both` result when that passage carries no text; on a `keyword` result past that cut-off, one whose text nothing matched, or one whose file has no indexed text; and at `?output=terse` (that tier skips the per-hit file read the quote is scoped by). `content_snippet`, `page` and `media_segment` are taken as a set from one passage, so they always describe the same passage rather than being filled in individually from different ones. A `null` snippet means there is no matching excerpt in this response — read the file itself for content. Trimmed per `output`. | | `files.{id}.match_source` | string | Which legs of the hybrid search matched this file: `keyword` (filename/text index only), `semantic` (content embedding only), or `both` (matched by BOTH legs — not merely by several semantic passages). Always `keyword` when intelligence is not enabled. | | `files.{id}.media_segment` | object\|null | `{start_seconds, end_seconds}` for audio/video matches. Hybrid mode only. | | `files.{id}.mimetype` | string\|null | File MIME type. Hybrid mode only. Populated on a `semantic`-or-`both` hit as before; a `keyword`-only hit now carries it too, filled from the file itself — except at `?output=terse` (that tier skips the per-hit file read that supplies it). | | `files.{id}.page` | object\|null | `{start_page, end_page}` for paginated documents. Hybrid mode only. A `keyword`-only hit carries one whenever the search quoted a passage for it — see `content_snippet`. | | `files.{id}.text_indexed` | boolean\|null | Whether the file has **any indexed text** at all — the question a `null` `content_snippet` cannot answer on its own. `false` means there is nothing indexed to quote, so reading the file is the only way to get at its content. `true` means the file's current version has indexed text (or, where the current version cannot be determined for the row, any still-active indexed version) — but **not** that this query matched it: a `true` beside a `null` snippet says the search did not return a passage for this query, not that the file has none. `null` means this response could not determine coverage — never that the file is unindexed. Present on **every** result row, in both hybrid and keyword-only mode. | | `files.{id}.summary_short` | string\|null | The stored short AI summary of the **whole file**. `null` when the file has no summary, and on a share search `null` whenever the caller's share role may not view summaries. **Always `null` at `?output=terse`** — the verbosity tier drops it, the same way it trims a node's summary — and always `null` on the keyword-only response. | | `files.{id}.best_chunk` | object\|null | The highest-scoring real **passage** of the file, as distinct from a whole-file summary: `{text, page, media_segment, score, result_type, position, sequence, indexed_version_id, same_as_snippet}`. `text` is the passage text, trimmed to the same byte budget as `content_snippet` under `output` (~200 bytes at `terse`, ~600 at `standard`, untrimmed at `full`); at `terse` and `standard`, `text` is `null` when `same_as_snippet` is `true` (read `content_snippet` for the text instead). `same_as_snippet` (boolean) is `true` when, after both are trimmed to the tier's byte budget, `text` was byte-identical to `content_snippet` — the caller then reads `content_snippet` for the text while still using `best_chunk`'s locator fields — and `false` otherwise; always `false` at the default `full`. `page` and `media_segment` are **both always present**, and **at most one of them is ever set**: `page` is `{start_page, end_page}` (1-based, inclusive) on a document passage (`result_type: "doc"`), `media_segment` is `{start_seconds, end_seconds}` on a transcript passage (`result_type: "transcript"`), and the other is `null` — both are `null` for a passage from a source with no locator. `score` is the passage's own **un-rescaled** retrieval score: on a `semantic` or `both` row it is on the content engine's scale — the same scale a `semantic` `raw_score` is on; on a keyword-backfilled row (see below) it is an in-file **text-match** score on a different scale. Never compare `score` across rows with different `score_source`. `result_type` is `doc` or `transcript`. `position` (integer\|null) is the passage's **0-based address in the file's chunk order** — the same address `GET /current/{workspace\|share}/{id}/storage/{node_id}/content/` takes as `chunk_from` / `chunk_to`, so it is how you read the passage and its surroundings without downloading the file. `sequence` (integer\|null) is the underlying ordering coordinate, absent on content indexed before it existed. `indexed_version_id` (string\|null) is the file version the address was worked out against, in the same form the content endpoint returns it: **compare it with the content response's own `indexed_version_id`, and if they differ the file was re-indexed in between, so the position is stale and the search should be re-run.** Both are `null` when this response could not resolve the address: they are published for **document text only** — an audio or video passage (`result_type: "transcript"`) never carries one, and neither does a passage that no longer opens a chunk after a re-index or one on a page too large to resolve addresses for (only the first 100 hits of a page get one). That is **not** a statement that the passage cannot be read. `null` when no qualifying non-summary passage was returned for that file on this query — the retrieval window may simply not have returned one, so this is not a statement that the file has no passages. **A `keyword`-only hit can carry one too**: for the top few results of the page, and only where `text_indexed` is `true`, the search quotes the best-matching passage of the file's indexed text so a filename or metadata match is not returned bare. On such a hit `score` is that passage's **text-match** score within its own file — it is on neither the `semantic` nor the row's own `raw_score` scale, and like every score here it is not a threshold; `result_type` is always `doc`, `media_segment` always `null`, and `position` / `sequence` / `indexed_version_id` are resolved exactly as for a passage the content search returned. It stays `null` on a `keyword` hit past that cut-off, on one whose text nothing matched, and at `?output=terse`. See *Passages vs summaries* below. | | `files.{id}.metadata_match` | boolean | `true` when the query **also** matched the file's **extracted metadata** — entity-style values such as a counterparty, a customer, or a document title. `false` otherwise. It commonly co-occurs with a filename or summary match; it does **not** mean the match happened instead of those. Read `match_source` for which retrieval legs matched, and *Result ordering* below for why a row sits where it does — a `true` here makes the row eligible for tier 3 unless a stronger filename tier (exact or prefix name match) already applies. | | `files.{id}.metadata_match_field` | string\|null | **Reserved — currently always `null`.** It will name the metadata field that carried the match once per-field matching exists. Do not branch on it today. | | `search_metadata` | object | `{intelligence_enabled, semantic_available, scoped}`, plus `scope_incomplete` when the applied scope was cut short, plus `scope_requested` / `scope_resolved` whenever `scoped` is `true`, plus the capability keys below when `search_in` was supplied. ⚠️ **Despite the name, this is not the files' metadata** — it describes THIS SEARCH (which channels were available, whether a scope applied). For extracted metadata, read the fields on each file, or `metadata_filter` for the outcome of a filter you sent. See *Knowing whether content search is possible*. | | `metadata_filter` | object | `{applied, matched, truncated, scope_incomplete, coverage}`. Present **only** when a `filters` value reached the server and ran. Its absence on a request you believe was filtered means the filter was dropped in transit — see *Filtering by metadata*. | **Passages vs summaries (`content_snippet` vs `best_chunk`).** `content_snippet` reports whichever piece of evidence ranked the file highest — and that can be the file's whole-document **summary**. A summary is not located anywhere in the file, so `page` is `null` on such a hit and there is nothing to jump to. `best_chunk` always reports a real **passage**, with the locator that belongs to it — `page` for a document passage, `media_segment` for a transcript passage, and the other one `null` — which is what you want when you are opening the file at the right place, quoting it, or citing a page. When the top-ranked evidence is already a passage, `best_chunk.text` carries the same text as `content_snippet` — as in the example above, which is at the default `full` tier where both stay populated and `same_as_snippet` is `false`. At `?output=terse` and `?output=standard`, once both are trimmed to the tier's byte budget, the server drops the duplicate instead: `best_chunk.text` is `null` and `best_chunk.same_as_snippet` is `true`, and the caller reads `content_snippet` for the text while still using `best_chunk`'s locator fields. **Passages on a filename or metadata match.** A `keyword` hit is found by the file's name and its extracted metadata, neither of which is document text, so such a row used to come back with nothing to quote even where the file's text was fully indexed. It is now quoted the same way any other row is: the search takes the best-matching passage of that file's indexed text and fills `content_snippet`, `page` and `best_chunk` from it. Three limits are worth knowing. It applies to the **top few results of the page**, not all of them, so a `null` on a lower-ranked `keyword` row is a budget, not a verdict. It applies only where `text_indexed` is `true`. And `best_chunk.score` on such a row is a **text-match** score inside that one file — do not compare it with a `semantic` passage's score, or with the row's own `raw_score`. **Reading around the passage.** `best_chunk.position` is the address the storage `content/` endpoint takes: send it back as `chunk_from` (and `chunk_to` a little higher) on `GET /current/{workspace|share}/{id}/storage/{node_id}/content/` to read the matching passage in full together with the text on either side of it, without downloading the file. Clamp both ends -- `chunk_from` at `0`, `chunk_to` at `9999`. Then compare `best_chunk.indexed_version_id` with the `indexed_version_id` that response returns: if they differ the file was re-indexed between the two calls, the position is stale, and the search should be re-run rather than the text quoted. `raw_score` and `score_source` are on `/storage/search/` only — the unified `/search/` route does not return them. **Result shape under `search_in=filename`.** A filename search has no content leg at all, so it returns the **keyword-only** item shape shown above — `name`, `parent_id`, `type`, `content_snippet: null`, `match_source: "keyword"`, plus `summary_short: null`, `best_chunk: null`, `metadata_match` and `metadata_match_field: null` — even on a workspace or share with AI features enabled. The hybrid-only fields (`relevance_score`, `raw_score`, `score_source`, `mimetype`, `media_segment`, `page`) are **absent**, because there is no content match to score or locate. If your client requires `relevance_score`, use `both` (the default) rather than `filename`. This shape is not new: it is exactly what every response looks like when AI features are off. The unified endpoints are unaffected — their `files` bucket keeps its usual item shape in every mode. ### Folder paths Every search hit says **where it lives**, so you do not have to list folders one at a time to find out. Three fields, one answer: - **`path`** — the ancestor folder names from the workspace or share root down to the folder holding the file, joined with `/`, no leading or trailing slash: `"Finance/Invoices/AR"`. The file's own name is not in it. `""` means the root. - **`ancestors`** — the same folders as `{id, name}` objects in the same order, with `id` in the formatted form `/storage/{parent_id}/list/` takes. Use this when you want to navigate rather than display. - **`path_complete`** — whether the walk reached the root. **Read this first.** When `path_complete` is `true`, `path` is exactly the `ancestors` names joined with `/`, and `[]` / `""` together mean the file is at the root. When it is `false`, `path` is `null` and `ancestors` carries only a best-effort **suffix** — the folders nearest the file, with an unknown number of levels missing from the top, never padded and never guessed. It goes `false` when a folder could not be read and past an internal depth limit. It is never a claim about where the file is. A hit that came back with an empty `parent_id` — which is every hit the meaning side found on its own — still gets a path: the server reads the file's own record to recover its folder. You do **not** need `details=true` for that, and the two retrieval legs answer alike. Paths are built when the response is assembled, so a folder rename shows up on the next call with no re-indexing delay. Through a share the root is the shared folder, so a path never names anything outside it. ⚠️ **`path` is a display value, not an address.** Folder names are returned exactly as stored and the `/` separator is **not escaped**, so a folder whose own name contains `/` is indistinguishable from a level boundary. Address folders with `ancestors[].id` or `parent_id`. ### Result ordering Results are ordered **tier first, then by `relevance_score` descending within a tier**. Two rows tie on the score readily — a file at position `r` of the name/text leg and another at position `r` of the content leg fuse to the same value — so the comparator has two further steps: a row whose `raw_score` is on the **keyword** scale (`score_source` `keyword` or `metadata`) sorts above one on the `semantic` scale, matching the rule that decides attribution on an equal position, and `node_id` ascending ends the comparison so the same result set always paginates the same way. The tiers, highest first: | Tier | What lands in it | |------|------------------| | 1 | Exact filename match | | 2 | Filename prefix match | | 3 | Metadata-entity match (`metadata_match: true`) | | 4 | Everything else | Rows in the fourth group are ordered by `relevance_score` descending. ⚠️ **A promoted hit can appear above a hit with a higher `relevance_score`.** This is the one thing most likely to surprise an integrator, and it is the ordering working as designed rather than a scoring bug: the tier is applied first, and `relevance_score` only breaks ties *inside* a tier. **Do not re-sort results by `relevance_score` client-side** — that throws the promotion away. To see why a row sits where it does, read `score_source` and `metadata_match`. **A promotion changes the ORDER, never the score.** All three promoting tiers work the same way: they place the row above the untiered results and leave `relevance_score` exactly as retrieval produced it. A promoted row is therefore identifiable by its position and by `score_source` / `metadata_match` — never by a special score value. The one field a promotion does change is `raw_score` on a **name** match (tiers 1 and 2): a name equality is not a measurement, so those rows report `score_source: "filename"` with `raw_score: null`. A metadata-promoted row (tier 3) keeps its `raw_score`, and that number is on the `keyword` scale **only where `score_source` is `metadata`** — that value is reported when the keyword leg ranked the file higher. A metadata-promoted row the content engine ranked higher reports `score_source: "semantic"` and carries the provider score. ### Knowing whether content search is possible A `content` search on an instance that cannot match content returns HTTP `200` with an **empty** result set, not an error. An empty list therefore has two very different meanings, and `search_metadata` is how you tell them apart: ```json "search_metadata": { "intelligence_enabled": false, "semantic_available": false, "scoped": false, "content_search_available": false, "reason": "intelligence_disabled" } ``` | Field | Type | Description | |-------|------|-------------| | `intelligence_enabled` | bool | Whether AI features are enabled on this workspace or share. | | `semantic_available` | bool | Whether the meaning-based channel actually served **this** request. This is the per-request outcome — read it, not `content_search_available`, to know what you just got. | | `scoped` | bool | Whether the meaning-based leg was actually narrowed by `files_scope` / `folders_scope`. It reports the narrowing that was **applied**, not the parameter you sent — sending a scope on a request where that leg does not run (AI features off, `search_in=filename`, or the leg failing) gives `scoped: false`, because nothing in those results was narrowed by it. | | `scope_incomplete` | `true` | Present, and always `true`, when the scope that was applied is **narrower than the one you asked for**: a `folders_scope` tree ran past the reference limit, so part of it was left out. Absent otherwise, and never present alongside `scoped: false`. A short answer would otherwise be indistinguishable from a complete one — you cannot count a folder's subtree before naming it. See *Scoping to files or folders*. | | `scope_requested` | int | How many scope entries you sent, counting `files_scope` and `folders_scope` together. Present only alongside `scoped: true` — see the note below. | | `scope_resolved` | int | How many of those entries the scope **resolved to**. Present only alongside `scoped: true`. A gap against `scope_requested` means part of what you named could not be resolved. ⚠️ It measures scope resolution, **not** final coverage: sending `filters` as well narrows the searched set further, and that further narrowing is not subtracted here. | | `content_search_available` | bool | Whether **either** content channel — the semantic index **or** summary access — is open here at all. A statement of *capability*, not a prediction of results. Present only when `search_in` was supplied. | | `reason` | string | Why content search is unavailable. Present only when `content_search_available` is `false`. Treat unrecognized values as opaque. | | `reason` | Meaning | What to do | |----------|---------|------------| | `intelligence_disabled` | AI features are off for this share, so meaning-based matching cannot run for anyone. | Retry with `search_in=filename`, or tell the user AI features must be enabled on the share. | | `summary_permission_denied` | AI features are on, but neither channel is open: the meaning-based channel did not serve this request, and this share's permissions do not let you search file summaries. | Retry with `search_in=filename`. This one is specific to you — another member of the same share may be permitted. | | `content_not_indexed` | Generic fallback for any other cause. | Retry with `search_in=filename`. | **Required client behavior:** when `content_search_available` is `false`, do **not** report "no files found." Either retry the same query with `search_in=filename` or tell the user that content search is unavailable here. This is the difference between recovering and confidently reporting a wrong answer. **What the flag does and does not tell you.** `content_search_available` answers *"can content search work here at all?"* — **not** *"will this query return results?"* It is `true` whenever either channel is open to you, so a `true` flag over an empty list is an ordinary "nothing matched," not a malfunction. For the per-request outcome, read `semantic_available`: it reports whether the meaning-based channel actually served **this** request. It is reachable as `false` only on **share** routes, and only on a share where *neither* channel is open. A workspace member may always search file summaries, so on `/workspace/{id}/storage/search/` the flag is always `true` and `reason` never appears — including when AI features are off for that workspace, because the summary channel is still open there. There is no workspace recovery path to code for. **When the block is emitted.** `search_metadata` is returned whenever the response is a hybrid one (AI features enabled), exactly as before. It is additionally returned on the keyword-only path when you explicitly supply `search_in`. The two capability keys (`content_search_available`, `reason`) appear **only** when you explicitly supply `search_in`. Supplying only `name_match` and/or `case_sensitive` does not change the response shape at all. **`semantic_available` is ALWAYS present; `content_search_available` is not — and they answer different questions.** `intelligence_enabled`, `semantic_available` and `scoped` are written unconditionally, so `semantic_available` is readable on every file-search response on either route. `content_search_available` is **derived and wider**: it is true when *either* the semantic channel **or** the AI-summary channel is open. ⚠️ **Summary access is always granted on a workspace** (it is permission-gated only on shares), so on a workspace `content_search_available` can never be `false` — do not use it to detect degradation there. The condition it cannot express is **`semantic_available: false` with `content_search_available: true`**: the meaning-based half degraded, keyword and summary still answered, and the response is a `200` carrying a partial result. **Read `semantic_available` for that.** And because `content_search_available` is omitted entirely when you did not send `search_in`, treat its ABSENCE as "not asked", never as `false`. ⚠️ **`search_metadata` sits in a DIFFERENT PLACE on the two search routes, and reading only one position is indistinguishable from "no metadata".** On this route (`/storage/search/`) it is **top level**. On the unified route (`/workspace/{workspace_id}/search/`) it rides **inside the files bucket**, at `buckets.files.search_metadata` — see *Unified search* below. Nothing in either response points at the other location, so a client that hard-codes one position reads `null` on the other route and will report a search as unscored, unscoped, or semantically unavailable when it was none of those. ⇒ **Read the top-level key first and fall back to `buckets.files.search_metadata`** (or branch on which route you called). Treat a missing block as "not reported", never as "the capability is off" — and note the two are not interchangeable in content either: the unified route carries no scope parameters, so its `scoped` is always `false` and it never emits `scope_incomplete` / `scope_requested` / `scope_resolved`. ### Filtering by metadata `filters` narrows a search to files whose **extracted metadata** satisfies a set of predicates, so one call answers *"find `payment terms` in open invoices over $1,000"* instead of two. It is available on the **workspace** route only. ⚠️ **On the share route `filters` is IGNORED, not refused.** The share search does not declare the parameter, so sending it there does not fail — the request returns `200` with the **unfiltered** result set and no `metadata_filter` block. Nothing in the response says the filter was discarded except that absent block, so a client that sends `filters` to a share and does not check for `metadata_filter` will present unfiltered results as filtered. `metadata_filter` is workspace-only for the same reason. If you need metadata narrowing on a share, do it client-side. The filter runs **first**, as a stage of its own, and produces the candidate set the search is then confined to. Both legs of the search obey it: filename/text matches outside the candidate set are dropped, and the meaning-based leg searches only the survivors. This is deliberately not a post-filter — you are searching *within* the filtered files, not filtering what a workspace-wide search happened to return. **A filter that matches nothing yields no results.** There is no fallback to an unfiltered search. If you asked for files satisfying a predicate and none do, the answer is an empty `files` map with `metadata_filter.matched: 0`. **A folder scope cannot be combined with `filters`.** Sending both `folders` and `filters` is refused with an input error rather than quietly answered. The filter stage does not narrow by folder, so *"inside this folder, matching this filter"* is not something this endpoint can answer accurately today — and answering it by ignoring the folder would search the whole workspace and hand back far more than you asked for, which is worse than saying no. Filter the whole workspace instead, or name specific `files`. Combining `filters` with `files` **is** supported: the result is the intersection of the two. **The predicate array.** `filters` is a JSON array of clause objects, each `{"field", "operator", "value"}` — the same shape the saved metadata filter `predicate` uses. Clauses are **AND**-combined. A request may carry at most **5** clauses. `field` names a field in the workspace's metadata vocabulary (canonical name or alias); the field's type is resolved server-side, so you never declare it. | Operator | Value | Meaning | |----------|-------|---------| | `=` `!=` `<` `<=` `>` `>=` | required | Compare the field against `value`. `!=` is the complement of `=`. Ordered comparison is not legal on boolean or JSON fields. | | `in` | required (non-empty list) | The field's value is one of the list. Counts as one clause. | | `exists` / `not_exists` | omitted | The field is present / absent on the file. | | `confidence_gte` | required (int `0`–`3`) | The extracted value's confidence band is at least this level. The levels are `0` = `low`, `1` = `medium`, `2` = `high`, `3` = `certain` — so `2` means "high or better". Legal on every field type, because it tests how the value was obtained rather than the value itself. Two traps below. | **`confidence_gte` — read these before using it.** The operator takes the integer, while a fact's `confidence` comes back as the band *name*, so the mapping is `0` = `low`, `1` = `medium`, `2` = `high`, `3` = `certain`. Send the integer (a decimal string such as `"2"` is also accepted); a band name such as `"high"` is rejected, as is any non-integer, including `2.0`. - **`confidence_gte: 3` excludes every AI-extracted value.** `certain` is reserved for deterministic sources (`exif`, `mediainfo`, `validated_server`); AI-extracted facts are capped at `high` on write. So `3` means "deterministic sources only", **not** "the most confident AI results". - **`confidence_gte: 0` is not "no minimum".** A value entered by a person has no extraction confidence (`confidence` is `null`), and a `null` confidence satisfies no level — including `0`. So `0` matches extracted values only and **drops every hand-entered value**. To match a field regardless of how it was obtained, use `exists` instead. A `value` can be a bare JSON integer, and the server compares it exactly as sent. **Do not round-trip such a value through a JavaScript `Number`** — anything above `Number.MAX_SAFE_INTEGER` (`9007199254740991`) silently loses precision if you `JSON.parse` it into a `Number` and re-serialize; pass it through unmodified. **curl example:** ```bash curl -G "https://api.fast.io/current/workspace/1234567890123456789/storage/search/" \ -H "Authorization: Bearer {jwt_token}" \ --data-urlencode "search=payment terms" \ --data-urlencode 'filters=[{"field":"status","operator":"=","value":"open"},{"field":"invoice_total","operator":">=","value":1000}]' ``` **Response:** ```json { "result": true, "files": { "2ltsuq4mjacuv7pgc5ydlxnsjwee4": { "name": "Invoice-2026-0042.pdf", "parent_id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "type": "file", "relevance_score": 1.0, "raw_score": 0.62, "score_source": "semantic", "content_snippet": "Payment terms are net 30 from the invoice date …", "match_source": "semantic", "media_segment": null, "mimetype": "application/pdf", "page": { "start_page": 1, "end_page": 1 } } }, "pagination": { "total": 1, "limit": 100, "offset": 0, "has_more": false }, "search_metadata": { "intelligence_enabled": true, "semantic_available": true, "scoped": false }, "metadata_filter": { "applied": true, "matched": 34, "truncated": false, "scope_incomplete": false, "coverage": { "scope": "files", "available": true, "files_in_scope": 40, "files_without_metadata": 28 } } } ``` **The `metadata_filter` block:** | Field | Type | Description | |-------|------|-------------| | `applied` | bool | Always `true` when the block is present. The block's **presence** is the signal — see below. | | `matched` | int | How many files satisfied the filter. This is the candidate count *before* the search narrowed it further and before any cap was applied to the meaning-based leg, so it is normally larger than the number of files returned. When `truncated` is `true`, treat it as a floor rather than an exact count. | | `truncated` | bool | `true` when the candidate set was clipped because it exceeded a cap. **Deterministic** — see below. | | `scope_incomplete` | bool | `true` when a transient fault dropped candidates that genuinely match. **Retryable** — see below. | | `coverage` | object | How much of the searched scope the filter could even see: `{scope, available, files_in_scope, files_without_metadata}`. Always present when `metadata_filter` is, and populated for a whole-workspace filter as well as an explicit `files_scope`. See *Coverage* below. | ⚠️ **`metadata_filter` is an acknowledgement that the filter RAN, and its absence is meaningful.** The block is emitted **only when a `filters` value actually reached the server and ran**. A request that sent no filter gets exactly the response it always got, with no such key. So, for a request you believe carried `filters`: - **`metadata_filter` present** → **the filter ran on the server**, and `matched` is trustworthy. - **`metadata_filter` absent** → **the filter did not reach the server.** The results in your hands are **unfiltered** and must not be treated as filtered. **What the block does not tell you.** `applied` certifies that the predicate executed — nothing more. It is *not* a statement that the result set is exactly what you asked for. `truncated` and `scope_incomplete` are the fields that qualify the result set, and you must read them separately. **`matched` describes the PREDICATE, not the PAYLOAD.** It counts the files the filter selected; it does not count the files this response contains. The two answer different questions and are routinely different numbers — `matched` is measured before the search narrows further and before any cap, so it is normally the larger of the two. Do not derive a result count from it, and do not treat `matched` being non-zero as a promise that `files` is non-empty. **Two things cause the key to be absent** on a request you believe carried `filters`: an intermediate client, proxy, or SDK that strips query parameters it does not recognise (at least one client library does), or a deployment that does not yet accept `filters`. You do not need to tell them apart — the correct action is the same either way: do not treat the results as filtered. Check for the key before trusting a filtered result. It is the only way to tell a filter that ran from one that never arrived: both come back `200` with a plausible-looking list of files. **`truncated` and `scope_incomplete` are different conditions — do not collapse them into "results may be incomplete."** One is worth retrying and the other never is: - **`truncated: true` — deterministic. Retrying changes nothing.** The candidate set was larger than a cap (the filter's own match ceiling, or the ceiling on how many files the meaning-based leg will accept) and was clipped. Re-running the identical request returns the identical answer. The fix is to **narrow**: add a clause, or make an existing one more selective. - **`scope_incomplete: true` — transient. Retrying is worth it.** A temporary fault dropped candidates that genuinely match your filter, so the answer is short for a reason that has nothing to do with your query. **Re-run the same request** — it may return more. Do not report the short list as complete. They are independent booleans and both can be `true` at once. `truncated` says *you asked for too much*; `scope_incomplete` says *we lost some of it*. **A filtered search only reaches files that have extracted metadata.** Predicates are evaluated against the metadata Fastio has already extracted for a file, so a file with no extracted metadata is not a candidate for **any** clause — including `not_exists`, which means "has metadata, but none for this field," not "has no metadata at all." Extraction is asynchronous, so a file added moments ago may not be filterable yet. To see what has been extracted for a file, read its `metadata_facts` on the node object. Read an empty filtered result as *nothing that has been extracted matches this filter*, not *no such files exist*. **Coverage — how many files the filter could not even see (`metadata_filter.coverage`).** Because a file with no extracted metadata is never a candidate, it is *absent* from a filtered result rather than *unmatched*, and `matched` cannot express that: a file the filter never got to evaluate is not a file it rejected. `coverage` puts a number on it. | Field | Type | Description | |-------|------|-------------| | `scope` | string | `files` when you narrowed the search with an explicit `files_scope`; `workspace` when the filter ran across the whole workspace. | | `available` | bool | Whether the two counts are real numbers. **This is the only field to branch on.** | | `files_in_scope` | int/null | How many files the search covered. `null` when `available` is `false`. | | `files_without_metadata` | int/null | How many of those hold no extracted metadata, and so could not match any clause. `null` when `available` is `false`. | - **`scope: "files"`, `available: true`** — you named the files, so both counts are exact. - **`scope: "workspace"`, `available: true`** — a whole-workspace filtered search reports coverage too. `files_in_scope` counts the **live files** in the workspace — files and notes, which are exactly the things metadata is extracted from. Folders, links and anything in the trash are not counted, because no clause could match them. `files_without_metadata` is that number minus the files that currently carry any metadata. The covered share — `files_in_scope` minus `files_without_metadata` — counts only files that are **both live and currently carrying metadata**. A file that has been deleted is never counted as covered, even when metadata was extracted from it before it was deleted. - **`scope: "workspace"`, `available: false`, both counts `null`** — the numbers could not be established for **this request**. Either the workspace is larger than the ceiling the two counts are read under (**5,000**), so the count stopped short of a total and a partial one would understate the uncovered share; or a count could not be read at all; or the two counts came back as a pair that cannot be true (see below); or a zero failed the final check described below. The search itself still answers normally — only this advisory block goes unfilled. The ceiling is there because establishing the covered count means checking every file that carries metadata against live storage, so the work grows with how many of them there are; the limit is set from what that costs. It bounds the counting only — it never limits the search itself. ⚠️ **That ceiling counts metadata RECORDS, not live files.** A file's metadata record outlives the file — deleting a file leaves it behind — so a workspace with a long history of uploads and deletions can be permanently past the ceiling while holding very few files today, and will report `available: false` on every filtered search. (Explicitly clearing a file's metadata does remove it from the count; deleting the file does not.) Nothing about the workspace's current size tells you whether coverage will be available for it. ⚠️ **`available: false` means the numbers are UNKNOWN, never that they are zero.** Rendering an absent count as `0` states that every file in scope has metadata, which nothing measured. Say nothing about coverage when `available` is `false`. ⚠️ **A pair of counts that cannot be true is reported as unknown, never as a zero.** The two whole-workspace counts are read separately, so a workspace changing underneath the request can produce a pair that is impossible — more covered files than there are files in scope. An impossible pair means one of the counts could not be trusted, so the block answers `available: false` with both counts `null` rather than publishing a coverage of `0`. A zero is the one value a caller would act on by trusting the result set, which is exactly what an untrustworthy count must not invite. ⚠️ **A `0` is VERIFIED against the actual files, not computed from two totals.** A `0` is the one answer a caller acts on — it says the predicate saw every file, so an empty result set is a real absence rather than a coverage gap — and a difference of two totals cannot support that claim: a covered file deleted and a different file appearing in the same moment leave both totals untouched while a real file goes uncovered. So before a `0` is published, the live files in scope are listed and every one of them is checked for a metadata record. The `0` means: **at the final read of this request, every live file in scope had metadata.** If any live file is missing one, or the list does not match the count being published, the block answers `available: false` instead. ⚠️ **`files_without_metadata` is not a fault count.** It counts files a predicate cannot evaluate at all, and that includes files with **nothing to extract** — an image carrying no text — as well as files nobody has run extraction over. A non-zero value is therefore not by itself a sign that anything is wrong; only a value of `0` proves the predicate saw every file in the scope. `coverage` is workspace-only for the same reason `metadata_filter` is: the share route accepts no `filters`, so no predicate runs there and neither block is emitted. **Combining `filters` with a scope.** `filters` and `files_scope` work together: the search runs over the **intersection** — the files you named that also satisfy the filter. `filters` and `folders_scope` **cannot currently be used together**, and sending both is refused with `1605 (Invalid Input)`. Either drop the folder scope and let the filter select across the whole workspace, or name the specific files you want with `files_scope`. This is a current limitation rather than a permanent rule, so handle the refusal as a condition rather than building on it as an invariant. Note that `search_metadata.scoped` reports only `files_scope` / `folders_scope`. A metadata filter is reported by `metadata_filter`, not by `scoped`, so `scoped: false` alongside a `metadata_filter` block is normal. **Scoping to files or folders (`files_scope` / `folders_scope`):** **Both narrow the meaning-based (semantic) leg only.** The filename/summary leg is deliberately not restricted by them, so a hybrid response can contain files from outside the scope carrying `match_source: "keyword"`. **For a hard boundary, filter on `match_source` yourself: keep the results marked `"semantic"` or `"both"`, and drop the ones marked `"keyword"`.** Both of the kept values mean the file came back from the meaning-based leg — the leg the scope narrowed — so they are the results that are genuinely inside the scope. ⚠️ **`search_in=content` is not a substitute for that filter.** `content` matches two channels, and only one of them is the meaning-based leg; the other is a keyword match against the file's AI-generated **summary**, which the scope does not restrict either. Wherever summary search is open to you, a scoped `search_in=content` request can therefore still return files from outside the scope, carrying `match_source: "keyword"`. Summary search is always open on the **workspace** routes, so `search_in=content` never bounds the result set there; on a **share** it depends on that share's permissions for you specifically. Filtering on `match_source` is the only approach that holds on every route and for every caller. Because the scope applies to that leg alone, it changes nothing at all when that leg does not run — AI features off on the workspace or share, `search_in=filename`, or the leg failing on this request. `search_metadata.scoped` reports that honestly: it is `true` only when the narrowing was really applied, so `scoped: false` on a request that carried a scope means the scope had no effect on what came back. **A scope that resolves to no files returns no meaning-based results** — never the whole workspace or share. A reference you name can drop out during resolution (the file was trashed, or a transient fault made it unreadable), and when every one of them does, the answer is an empty meaning-based result set rather than an unscoped search. An empty scope is still a scope: the empty one. **`scope_requested` / `scope_resolved` tell you how much of the scope you named could be resolved.** They count **entries** — the comma-separated items you wrote across `files_scope` and `folders_scope` — not the folders a tree expands into. A gap between them means part of what you named could not be resolved, which an empty result set alone can never show you: *"the folders resolved and matched nothing"* and *"the folders no longer resolve"* come back byte-identical without this pair. 🔴 **They measure SCOPE RESOLUTION, not the final searched set.** The pair is counted when the scope is parsed and resolved. If you also send `filters`, the metadata predicate intersects with your named scope **afterwards** and can shrink the searched set further — that later narrowing is **not** subtracted from `scope_resolved`. So on a combined `files_scope` + `filters` request, `scope_requested: 2, scope_resolved: 2` is entirely consistent with only one file actually being searched. Read the pair as *"how much of what I named survived resolution"*, never as *"how many files were searched"*; for the filter's own outcome read the `metadata_filter` block instead. ⚠️ **The pair reports counts, never a cause.** A gap can hold entries that failed to resolve, entries left out once the scope hit its reference limit, and entries after that limit that were never looked up at all. `scope_incomplete` tells you the limit was reached; it does not divide the gap between those three. ⚠️ **A missing entry does not mean a deleted file.** An entry that does not resolve covers both a node that is genuinely gone and one that merely could not be read on this request — a transient fault, for instance. Do not render this pair to a user as *"those folders no longer exist"*; *"part of what you selected could not be searched"* is what it actually supports, and a retry is worth trying before you tell anyone their files are missing. Both keys are present **only alongside `scoped: true`**. Where no narrowing was applied there is nothing for them to describe — and because the scope narrows the meaning-based leg alone, a `search_in=filename` request that still carried a scope would otherwise report everything you named as unresolved when none of it was ever looked up. Whenever `scoped` is `true` **both keys are always present**, including as `0` and `0`, so you never have to tell "this response does not report counts" apart from "nothing resolved". **Entries are validated, not silently dropped.** A value that is not a `nodeId:versionId` / `nodeId:depth` pair at all — including a bare `0` — a pair whose `versionId` is not a version of the file it is paired with, an id that is not a valid node or version id, or a node of a type the parameter does not take — a folder in `files_scope`, a file or note in `folders_scope`, or a link in either — is refused with `1605 (Invalid Input)` / `406`, and the message names the entry that was wrong. This matters because ids render both hyphenated and unhyphenated for the same value, so a pair assembled from two different responses is easy to mismatch by accident while each half stays individually well-formed. To send no scope, **omit the parameter** rather than sending a placeholder value. **`files_scope` takes files AND notes; `folders_scope` takes folders; links cannot be scoped.** Search results carry a `type` of `file`, `folder`, `link` or `note`. Notes are indexed the same way files are and are returned by meaning-based search, so `files_scope` accepts a note's `nodeId:versionId` pair exactly as it accepts a file's — if a note came back as a hit, you can scope your next query to it. A **link** has no stored content to index and is accepted by neither parameter. A node of the wrong type for the parameter it was named in is refused with `1605 (Invalid Input)` / `406`, and the message names the type the node actually is and, where the other parameter would take it, which one to use instead. **Both parameters are read from the query string only.** These are `GET` endpoints and the scope is a query parameter; a scope sent in a request body is not read, and the search runs unscoped. Put `files_scope` / `folders_scope` in the URL. **Folder aliases are not accepted.** `folders_scope` takes a folder's own node id. The `root` and `trash` aliases are refused with `1605 (Invalid Input)` / `406`, and the message names the alias you sent. To search everything, omit `files_scope` and `folders_scope` rather than scoping to the root. **A scope carries at most 100 references in total**, counting every file you name plus every folder you name plus every subfolder reached by expanding a `folders_scope` entry to its `:depth`. Naming more than 100 **files** is refused. A **folder** tree that runs past the limit is not refused — you cannot count a subtree before naming it — so it is **truncated instead, and the truncation is reported**: the response carries `search_metadata.scope_incomplete: true`, meaning the search covered less than you asked for. Narrow the `:depth`, or name fewer folders, and retry. **Verbosity (`output`):** `content_snippet`, `best_chunk.text`, `best_chunk.same_as_snippet` and `summary_short` are the fields affected by `output=`. All other fields are returned unchanged at every level. | `output` | `content_snippet` and `best_chunk.text` shape | `summary_short` | |----------|--------------------------|-----------------| | `terse` | First ~200 bytes of the matching chunk, UTF-8 safe. Truncated values end with `…`. | Always `null` — the tier drops it, the same way it trims a node's summary. | | `standard` | First ~600 bytes (roughly one paragraph). Same `…` suffix when truncated. | Returned in full. | | `full` (default) | Full matching chunk, untrimmed. | Returned in full. | Snippets shorter than the budget are returned unchanged (no padding, no `…`). Null/empty snippets are returned unchanged at every level. The byte budget is inclusive of the trailing `…` so the wire payload never exceeds the cap. `best_chunk.text` follows the same budget and the same `…` rule as `content_snippet`; at `terse` and `standard`, if the trimmed `best_chunk.text` then comes out byte-identical to the trimmed `content_snippet`, `text` is replaced with `null` and `best_chunk.same_as_snippet` is `true` (otherwise `text` is present and `same_as_snippet` is `false`) — at `full`, `text` is never suppressed and `same_as_snippet` is always `false`. The rest of `best_chunk` (`page`, `media_segment`, `score`, `result_type`, `position`, `sequence`, `indexed_version_id`) is returned unchanged at every level. Default `output=full` preserves the prior `best_chunk.text` value and never deduplicates it; the `same_as_snippet` key is the one additive change at that level. **A snippet is an excerpt, not the passage.** When you need the surrounding text in full -- to quote a clause, or to read what the match sits in -- do not widen `output` and hope: call the file's *Node Content* endpoint with `?q=` (the same query terms) and it returns that file's best-matching chunks with their complete text, page ranges, and no snippet budget at all. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | `search_in` or `name_match` is not one of the listed values; `case_sensitive` is not `true`/`false`/`1`/`0`; or the pattern is empty / longer than 256 characters / a `glob` of nothing but `*` and `?` | | `1605 (Invalid Input)` | 406 | `filters` is not a JSON array of clause objects, or a clause is missing a `field` / `operator` (workspace only) | | `1605 (Invalid Input)` | 406 | The filter could not be applied: more than 5 clauses, a field the workspace vocabulary does not have, or an operator/value that does not suit the field's type (workspace only) | | `1605 (Invalid Input)` | 406 | `filters` was combined with `folders_scope` — not currently supported together (workspace only) | | `1605 (Invalid Input)` | 406 | A `files_scope` / `folders_scope` entry is wrong: not a `nodeId:versionId` / `nodeId:depth` pair at all (a bare `0` included), an id that is not a valid node or version id, a `versionId` that is not a version of that file, or a node of the wrong type — a folder in `files_scope`, a file or note in `folders_scope`, or a link in either. The message names the offending entry and the type the node actually is | | `1605 (Invalid Input)` | 406 | `folders_scope` named the `root` or `trash` folder alias. It takes folder node ids only — send the folder's own node id, or omit the scope entirely to search everything | | `1609 (Not Found)` | 404 | Search not available for workspace folder shares (share only) | | `1680 (Access Denied)` | 401 | No search permission (share only) | **Notes:** - Share search is not available for workspace-backed shares (shared folders). - Search results are filtered by the user's file view permissions. - Omitting `search_in`, `name_match`, and `case_sensitive` reproduces the exact query, ranking, and response keys this endpoint returned before they existed. - `search_in=filename` skips the content lookup entirely rather than running it and discarding the result, so it is also the fastest mode. - The pattern rules are checked whenever a precise `name_match` is *asked for*, even in combination with `search_in=content` where the filename is not matched at all — a request that would be silently ignored is rejected instead. - A single search examines at most **1,000** matching files. A deliberately broad pattern (`*`, `*a*`) can reach that ceiling and return a truncated view, so prefer the narrowest pattern that answers the question. - The precise `name_match` values (`exact`, `prefix`, `contains`, `glob`) depend on filename indexing that is rolled out per environment; `auto` works everywhere. If a precise match returns nothing where you expect a hit, retry with `name_match=auto` before concluding the file is absent. - Omitting `filters` reproduces the exact response this endpoint returned before it existed — no `metadata_filter` key is added. - Sending `filters` as an empty array (`[]`) is treated as no filter at all: the search runs unfiltered and no `metadata_filter` block is returned. - `filters` is accepted on the **workspace** route only, and the share route **ignores it rather than refusing it** — the request succeeds and returns the **unfiltered** results, with no `metadata_filter` block. A `406` would tell you the parameter did not apply; a `200` does not, so the absent block is the only signal and you have to look for it. Same for `metadata_filter`: workspace-only. - Always read `metadata_filter` before presenting a filtered result. A missing block means the results are unfiltered, and a `truncated` or `scope_incomplete` flag means they are partial for two very different reasons. --- ## Metadata Search (Workspace Only) ``` GET /current/workspace/{workspace_id}/metadata/search/ ``` Keyword search across the metadata stored on workspace files. Returns matching nodes ordered by relevance. Trashed nodes are filtered out automatically, as are files whose metadata has all been deleted. The endpoint is workspace-scoped — results never cross workspace boundaries regardless of caller input. **This now searches the current metadata corpus.** It previously searched only the older key-value store, which stopped receiving new values when automatic extraction moved to the current model — so recently extracted metadata was not findable here even though it was visible on the file itself. If you have searched for a value you could see on a file and got nothing back, that is the gap this closes; no request change is needed. **Every result now tells you which metadata field matched.** A metadata hit used to carry only the node, its name and a score — exactly what a filename hit carries — so there was no way to show a user *why* a file came back, and a correct match was indistinguishable from a match on the filename. Each result now includes `matched_fields`: the field name(s) whose value matched, each with the matching value. This is additive; every field that was there before is unchanged. **Auth required.** Permission: View (workspace member). **Query parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `q` | string | Yes | - | Keyword query. Whitespace-trimmed; an empty value is rejected. | | `template_id` | string | No | - | **Retired — supplying it is now an error (406).** It used to restrict matches to nodes carrying a value contributed by that template; the searched corpus no longer records a template association, so the filter cannot be honoured. It is REFUSED rather than ignored, because silently accepting a narrowing filter and then returning every match is a widening a caller cannot detect. Remove the parameter. | | `limit` | int | No | 100 | Maximum number of results (1-100). | | `offset` | int | No | 0 | Number of results to skip. Requests beyond the supported result window return an input error. | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/metadata/search/?q=invoice&limit=25" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "results": [ { "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "score": 4.215, "template_ids": [], "matched_fields": [ { "field": "document_type", "value": "Invoice", "value_truncated": false }, { "field": "vendor", "value": "Invoice Systems Ltd", "value_truncated": false } ], "matched_fields_truncated": false, "node": { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "Invoice-2026-0042.pdf", "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "size": 524288, "mimetype": "application/pdf", "mimecategory": "document", "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "created": "2026-04-20 14:12:08 UTC", "modified": "2026-04-22 09:30:55 UTC", "restricted": false, "dmca": false, "locked": false } } ], "pagination": { "total": 1, "limit": 25, "offset": 0, "has_more": false } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `results` | array | Matching nodes, ordered by relevance score (highest first) | | `results[].node_id` | string | OpaqueId of the matching node | | `results[].score` | number | Relevance score for the match | | `results[].template_ids` | array of string | **Always empty.** Templates are retired and the searched corpus records no template association. The key is kept so existing clients keep parsing, but it will not be populated again. | | `results[].matched_fields` | array of object | The metadata field(s) whose value matched this query, in the order they are stored on the file. May be empty — see *Reading `matched_fields`* below. Omitted entirely — not empty — for callers below Member permission; see *Access* below. | | `results[].matched_fields[].field` | string | The field name, exactly as it is spelled on the file. Case and accents are preserved verbatim; do not fold or normalise it before matching it against your own field list. | | `results[].matched_fields[].value` | string | The matching value, as text. Long values are shortened — see `value_truncated`. | | `results[].matched_fields[].value_truncated` | bool | `true` when the stored value is longer than the returned text. The returned text is a window taken around the part that matched, so it always contains the match — it is not simply the beginning of the value. | | `results[].matched_fields_truncated` | bool | `true` when the `matched_fields` list is known to be **incomplete** — more fields matched than are listed, or part of this file's metadata was too large to be searched. Present it as "matched X and more", not as the full list. | | `results[].node` | object | Standard node resource (same shape as storage list/details) | | `pagination.total` | int | Total number of matching nodes | | `pagination.has_more` | bool | `true` when more results exist past the current window | **Access.** `matched_fields` and `matched_fields_truncated` are returned only to callers holding **Member** permission on the workspace — the same level every dedicated metadata read requires. The route itself is unchanged and still opens at View: a caller below Member still searches metadata and still receives every matching file, its score, its ranking and its full node payload. The two keys are simply **not present** on each result. Read them with a **presence check on the key**, not with a length check. An **absent** `matched_fields` means the caller is not cleared to see metadata values. An **empty** `matched_fields` means something different and unrelated: the match could not be attributed to a specific field on that file. Do not treat the two as the same state, and never suppress a hit for either one. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | `q` missing/blank, `template_id` supplied at all (the filter is retired — remove it), or a request beyond the supported result window | | `1680 (Access Denied)` | 401 | Caller is not a member of the workspace | | `1654 (Internal Error)` | 500 | Search backend transient failure | **Notes:** - Returns at most one entry per node, even when multiple metadata fields on that node matched — the fields that matched are listed in `matched_fields` on that single entry. - Matching is **substring and case-insensitive** — `inv` already matches `invoice`, so no wildcard is needed. `*` and `?` in your query are treated **literally**, not as wildcards: `inv*` searches for a literal asterisk and will NOT match `invoice`. A multi-word query requires ALL of its words to be present, not any. - Indexing is event-driven — newly written or updated metadata typically becomes searchable within a few seconds. - Binary metadata values are not indexed; only textual values (including JSON leaves) participate in matching. - Each indexed document is bounded; metadata values exceeding the per-node ceiling are truncated for indexing only — source data is unaffected. - This endpoint is independent of `/storage/search/` — that endpoint searches filenames and file content, this one searches metadata field values. To search content *within* files selected by their metadata, use `/storage/search/` with its `filters` parameter instead (see *Filtering by metadata* under *Search*). - **Reading `matched_fields`.** Values are truncated to at most 256 characters, and at most 10 fields are listed per result; both limits are reported rather than applied silently (`value_truncated` and `matched_fields_truncated`). An **empty** `matched_fields` is a valid result, not an error — it means attribution was not available for this file, so the match could not be tied back to a specific field. The result itself is still correct: the file genuinely matched. Render it normally when the list is empty; never suppress the hit. An **absent** `matched_fields` is a different state entirely — the caller is below Member permission; see *Access* above. - Field names come from the metadata on the file and are **caller data**. Compare them byte-for-byte against your own vocabulary; a lowercased or accent-stripped comparison will miss fields that genuinely matched. --- ## Compound Search (Metadata Filter + Content Query) ``` POST /current/workspace/{workspace_id}/metadata/compound-search/ ``` **Two inputs, never one blended string.** A structured metadata filter selects the candidate files, then a semantic content query ranks the ones whose *content* answers the question. Use it for "the contracts signed last quarter that mention early termination": the quarter is a metadata predicate, the clause is a content question, and neither half alone answers it. Workspace only — there is no share form. **Auth required.** Permission: **Member** on the workspace. The organization's plan must include **both** the `metadata` and `content_ai` features, and the workspace must have **Intelligence enabled**: the second stage searches the content index the Intelligence pipeline builds, so a workspace with Intelligence off is refused up front rather than handed a misleadingly empty result. There is no keyword leg to fall back on here — where `/storage/search/` degrades, this endpoint refuses. **Parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `filters` | string (JSON) | Yes | - | JSON-encoded predicate array — `[{"field": "...", "operator": "...", "value": "..."}]`. Must be a non-empty list of objects, each carrying a string `field` and `operator`. Same predicate vocabulary as saved metadata filters; see *Filtering by metadata* under *Search*. | | `content_query` | string | Yes | - | The content question. Max 1024 characters; blank is rejected. | | `limit` | int | No | server default | Result-count cap, minimum 1. A value above the server maximum is **clamped, not rejected**, and the clamp is reported as `scope.limit_clamped_from`. | 🔴 **`filters` is a FORM FIELD whose value is a JSON string — not a JSON request body.** This is the most common way to call this endpoint wrong. Send the request form-encoded (`application/x-www-form-urlencoded` or `multipart/form-data`). A request sent as `Content-Type: application/json` **does not populate `filters` at all**, and is refused exactly as though you had sent no filters — `406` with `error.code` `119701` — which reads as "my filter is invalid" when the real problem is how the body was encoded. **The three parameters are not symmetric about this.** `content_query` and `limit` are read from either the POST body or the query string, so they survive being sent either way. `filters` is read from the POST body **only** — there is no query-string form of it, and it is the one that goes missing. **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/metadata/compound-search/" \ -H "Authorization: Bearer {jwt_token}" \ --data-urlencode 'filters=[{"field":"document_type","operator":"=","value":"contract"}]' \ --data-urlencode "content_query=early termination clause" \ --data-urlencode "limit=25" ``` **Response (200 OK):** ```json { "result": true, "items": [], "scope": { "match_count": 12, "match_relation": "eq", "scope_used": 100, "scope_truncated": false, "files_not_indexed": 3, "limit_clamped_from": null, "causes": [] } } ``` `items` holds standard node resources — the same shape as storage list/details, ranked by content relevance, honouring the request's `output=` tier. **Read the `scope` object; it is the point of this endpoint.** A short `items` list can mean "twelve files matched" or "far more matched and the answer was cut short", and `scope` is what tells the two apart. | Field | Type | Description | |-------|------|-------------| | `scope.match_count` | int | Matching files — those satisfying the filter **and** the content query. Always equals the number of entries in `items`. | | `scope.match_relation` | string | `eq` when `match_count` is exact, `gte` when it is a **floor** because a cap bounded the answer. | | `scope.scope_used` | int | How many candidate files the content stage actually searched. | | `scope.scope_truncated` | bool | `true` when more indexed candidates existed than the content stage could search. | | `scope.files_not_indexed` | int/null | Candidates the filter matched that had no content index entry and were therefore invisible to the content stage. `null` means the coverage read itself was unavailable — unknown, not zero. | | `scope.limit_clamped_from` | int/null | Your original `limit` when it exceeded the server maximum, else `null`. | | `scope.causes` | array of string | **Every** bound that fired, with no precedence between them. `[]` when nothing bounded the answer. | **`causes` values and what to do about each:** | Cause | Meaning | Remedy | |-------|---------|--------| | `filter_cap` | The filter matched more files than the metadata stage will carry forward | **Narrow the filter** | | `scope_cap` | More indexed candidates existed than the content stage's budget allows | **Narrow the filter** — the same remedy, not an upgrade | | `result_budget` | The content stage filled the requested `limit`, so more may match beyond it | Raise `limit`, or make the query more specific | | `coverage_unavailable` | The index-coverage read degraded, so `files_not_indexed` is unknown | Retry if completeness matters | Treat `causes` as an open set: handle the values you know and fall back for the rest. Only the count-bounding causes (`filter_cap`, `scope_cap`, `result_budget`) make `match_relation` a floor, so `causes` can be non-empty while `match_relation` is still `eq` — a degraded coverage read does not clip the candidate set. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `115280` | 406 | Intelligence is not enabled on this workspace | | `119701` | 406 | `filters` is missing, blank, not a JSON array, or an empty array | | `116139` | 406 | An element of `filters` is not an object | | `155870` | 406 | An element of `filters` lacks a non-empty `field` or `operator` | | `100859` | 406 | `content_query` missing or blank | | `179646` | 406 | The predicates are not valid for the fields they name — a wrong operator for the field's type, an unusable value, or too many clauses | | varies per call site — read `error.code` from the response | 401 | Caller is below Member on the workspace | | `109283` | 503 | A transient storage fault left the candidate set incomplete, so the answer cannot be trusted — retry | | `135817` | 503 | A transient fault in either stage — retry | | `193826` | 500 | The workspace's storage instance could not be resolved | | `134988` | 500 | A permanent, non-validation backend fault | **Notes:** - A `503` here is specifically **not** an empty result. The endpoint refuses rather than returning a partial answer that looks complete. - This is distinct from `/metadata/search/`, which is a keyword search over metadata values only and never reads file content. - To filter by metadata inside a plain content search instead, use `/storage/search/` with its `filters` parameter — see *Filtering by metadata* under *Search*. --- ## Unified Search (Grouped by Type) ``` GET /current/workspace/{workspace_id}/search/ GET /current/share/{share_id}/search/ ``` One search call across everything in a workspace or share, with results **grouped by type** into buckets. Instead of calling the per-type search endpoints separately, you issue a single query and get back a set of buckets — each with its own results and its own pagination. A workspace search returns up to three buckets: `files`, `metadata`, and `comments`. A share search returns the subset that applies to shares (typically `files`, plus `comments` when commenting is enabled on the share; `metadata` is workspace-only). Each bucket is independently paginated and independently health-reported, so a transient problem affecting one bucket never blocks the others — that bucket comes back `degraded` with an empty result set while the rest return normally. **Auth required.** Permission: View (workspace), search + file view permissions (share). All buckets are **permission-filtered** — see *Permission model* below. **Query parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `search` | string | Yes | - | Search query string (max 1024 characters; an empty value is rejected). | | `files_offset` | int | No | 0 | Result offset for the `files` bucket. | | `files_limit` | int | No | 25 | Page size for the `files` bucket. | | `metadata_offset` | int | No | 0 | Result offset for the `metadata` bucket (workspace only). | | `metadata_limit` | int | No | 25 | Page size for the `metadata` bucket (workspace only). | | `comments_offset` | int | No | 0 | Result offset for the `comments` bucket. | | `comments_limit` | int | No | 25 | Page size for the `comments` bucket. | | `search_in` | string | No | `both` | **`files` bucket only.** Which side of the file to match: `filename`, `content`, or `both`. | | `name_match` | string | No | `auto` | **`files` bucket only.** How the filename is matched: `auto`, `exact`, `prefix`, `contains`, or `glob`. Ignored when `search_in=content`. | | `case_sensitive` | string | No | `false` | **`files` bucket only.** `true` / `false` / `1` / `0`. Applies to the precise `name_match` values; ignored under `auto`. | | `details` | string | No | - | `"true"` enables `metadata_facts` on `file`/`note` items in the `files` bucket, shaped by `output` exactly as documented under *Extracted Metadata Facts*; any other value is treated as absent (no error). Folders and links never carry it. | | `output` | string | No | `full` | Verbosity: `terse`, `standard`, or `full` (default). Trims `content_snippet` on `files`-bucket items to a byte budget, and shapes `metadata_facts` when `details=true`. See *Verbosity* below. | **Search modes apply to the `files` bucket only.** `search_in`, `name_match`, and `case_sensitive` behave exactly as documented under *Search* above — same values, same defaults, same escaping and case rules, same pattern limits, same `1605 (Invalid Input)` on a bad value. They shape only the `files` bucket; the `metadata` and `comments` buckets are unaffected and keep matching as they always have. Omit all three and the response is byte-for-byte what it was before they existed. `search_in=filename` is the most useful of the three here: it turns the `files` bucket into a pure filename lookup while still returning `metadata` and `comments` matches from the same call. **`details=true` adds extracted metadata facts to the `files` bucket.** Each `file` or `note` item whose facts were read then carries `metadata_facts` -- the same block `/storage/search/` and the node object emit, in the same three `output` shapes (`terse`, `standard`, `full`) with the same per-tier caps and the same `count`/`total`/`is_truncated` semantics -- see *Extracted Metadata Facts* under *Node Object Schema* above. Folder and link items never carry the key, matching every other surface. Visibility follows the same rule as everywhere else `metadata_facts` appears: the key is **omitted entirely** (never emitted empty) when the caller is not entitled to extracted metadata. Because extracted metadata is a workspace-only surface, that omission is unconditional on the share twin -- `/share/{share_id}/search/` never returns `metadata_facts` regardless of `details`, for any share role. On the workspace route it is present for callers who are members of the owning workspace. With `details` absent or any string other than `"true"`, the response is unchanged from before this parameter existed -- no new key; a non-string value is rejected as invalid input, like any other declared parameter. Cost is **at most one batched facts read per `files`-bucket page**, not one read per item, so `details=true` is cheap here even at `files_limit=25`. **Verbosity (`output`):** `content_snippet` on `files`-bucket items is trimmed to a byte budget by the `output` level -- the same budget `/storage/search/` applies, so every search surface sizes the field the same way. | `output` | `content_snippet` shape | |----------|-------------------------| | `terse` | First ~200 bytes of the matching chunk, UTF-8 safe. Truncated values end with `…`. | | `standard` | First ~600 bytes (roughly one paragraph). Same `…` suffix when truncated. | | `full` (default) | Full matching chunk, untrimmed. | The budget is inclusive of the trailing `…`, so the wire payload never exceeds the cap, and the `…` is appended only when the value was actually cut. Snippets shorter than the budget are returned unchanged (no padding, no `…`), and a `null` snippet -- what a keyword-only hit carries -- is returned unchanged at every level. Only the `files` bucket is affected: the `metadata` and `comments` buckets are identical at every level, and `content_snippet` is the only field trimmed here, because this route returns no `best_chunk` and no `summary_short`. **`output` sizes the snippet whether or not you send `details=true`** -- the two parameters are independent, and `details` governs only whether `metadata_facts` is added. As on `/storage/search/`, when you need the surrounding text in full, do not widen `output`: call the file's *Node Content* endpoint with `?q=` and read the whole chunk with no snippet budget at all. Pagination is **per bucket**: `files_offset`/`files_limit` page the files bucket, `comments_offset`/`comments_limit` page the comments bucket, and so on. Each pair is optional; an omitted offset defaults to `0` and an omitted limit to `25`. A request beyond the supported result window for any bucket returns a `406` input error. Every applicable bucket is always searched (the share endpoint omits `metadata`). **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/search/?search=quarterly+report&files_limit=10&comments_limit=5" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "buckets": { "files": { "items": [ { "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "name": "Q4 Report.pdf", "parent_id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "type": "file", "relevance_score": 1.0, "content_snippet": "Quarterly revenue grew 18% year-over-year …", "match_source": "both", "media_segment": null, "page": { "start_page": 3, "end_page": 3 }, "mimetype": "application/pdf", "updated": "2026-04-22 09:30:55 UTC" } ], "offset": 0, "limit": 10, "total": 1, "total_relation": "eq", "has_more": false, "status": "ok" }, "metadata": { "items": [ { "node_id": "23kgh-fgzmg-72knw-u676u-vibbh-jizb", "name": "Invoice-2026-0042.pdf", "parent_id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "type": "file", "relevance_score": 4.215, "template_ids": [], "matched_fields": [ { "field": "document_type", "value": "Invoice", "value_truncated": false } ], "matched_fields_truncated": false, "updated": "2026-04-22 09:30:55 UTC" } ], "offset": 0, "limit": 25, "total": 1, "total_relation": "eq", "has_more": false, "status": "ok" }, "comments": { "items": [ { "comment_id": "caaq5-twdpg-axqnt-bbtt4-v2xit-unmv2", "entity": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "author_profile_id": "1234567890123456789", "snippet": "Can we double-check the Q4 totals …", "reference_type": null, "relevance_score": 2.118, "created": "2026-04-21 08:02:11 UTC", "updated": "2026-04-21 08:02:11 UTC" } ], "offset": 0, "limit": 5, "total": 1, "total_relation": "eq", "has_more": false, "status": "ok" }, } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `buckets` | object | Map of bucket type → bucket object. Only applicable buckets are present (e.g. no `metadata` on a share). | | `buckets.{type}.items` | array | Result items for this bucket. The `metadata` and `comments` buckets are ordered by `relevance_score` descending. The **`files`** bucket is ordered **promotion tier first** — an exact filename match, then a filename prefix match, then everything else — and only then by `relevance_score` descending, then a `keyword`-scale row above a `semantic`-scale one, then `node_id` ending the comparison; it shares its ranking with `/storage/search/`, so **a name match can sit above an item with a higher `relevance_score` here too**. (The metadata-entity tier is `/storage/search/`-only and never applies on this route.) Item shape is type-specific — see *Bucket item shapes* below. | | `buckets.{type}.offset` | int | The offset applied to this bucket. | | `buckets.{type}.limit` | int | The page size applied to this bucket. | | `buckets.{type}.total` | int | Number of matching, permission-visible items. This count is computed **after** permission filtering — it reflects what you can actually see, not raw index hits. | | `buckets.{type}.total_relation` | string | `eq` when `total` is exact within the searched window, or `gte` when it is a lower bound (more visible matches may exist beyond the searched window). | | `buckets.{type}.has_more` | bool | `true` when more results exist past the current page. | | `buckets.{type}.status` | string | `ok` for a healthy bucket, or `degraded` when the backend behind that bucket was temporarily unavailable (the bucket returns an empty `items` array but is still present so you can tell a backend hiccup from a bucket that does not apply). | | `buckets.files.search_metadata` | object | Capability report for the `files` bucket. Present **only** when `search_in` was explicitly supplied. See below. | **`buckets.files.search_metadata`:** The unified response has no top-level metadata slot, so the capability report rides on the bucket it describes. ⚠️ **This is the reciprocal of the `/storage/search/` placement** — there the same block is **top level**. A client that reads only one position gets `null` on the other route, which is indistinguishable from "no metadata": **read the top-level key first, then fall back to `buckets.files.search_metadata`**, or branch on which route you called. ```json "buckets": { "files": { "items": [], "offset": 0, "limit": 25, "total": 0, "total_relation": "eq", "has_more": false, "status": "ok", "search_metadata": { "intelligence_enabled": false, "semantic_available": false, "scoped": false, "content_search_available": false, "reason": "intelligence_disabled" } } } ``` The fields and the `reason` values are identical to the `/storage/search/` block documented under *Knowing whether content search is possible* above, and the same client rule applies: on `content_search_available: false`, do not report "no files found" — retry with `search_in=filename` or say that content search is unavailable. `scoped` is always `false` here because this endpoint has no `files_scope` / `folders_scope` parameters. As on `/storage/search/`, `content_search_available` reports *capability*, not result likelihood — it is reachable as `false` only on share routes, and `semantic_available` is the field that tells you what this particular request got. The block is absent unless you supply `search_in` — supplying only `name_match` and/or `case_sensitive` leaves the response shape unchanged. **Bucket item shapes:** Every item carries a `relevance_score` (higher is more relevant) and an `updated` timestamp. Beyond that, fields are type-specific: - **`files`** — `node_id`, `name`, `parent_id`, `type`, plus (when workspace/share intelligence is enabled) the hybrid-match fields `content_snippet` (the matching text, `null` for keyword-only matches, trimmed per `output`), `match_source` (`keyword` / `semantic` / `both`), `media_segment` (`{start_seconds, end_seconds}` for audio/video, else `null`), `page` (`{start_page, end_page}` for paginated documents, else `null`), and `mimetype`. A `file` or `note` item additionally carries `metadata_facts` when the request set `details=true` -- see *`details=true` adds extracted metadata facts to the `files` bucket* above. - **`metadata`** (workspace only) — `node_id`, `name`, `parent_id`, `type`, `matched_fields` + `matched_fields_truncated`, and `template_ids`. `matched_fields` lists the metadata field(s) whose value matched, each as `{field, value, value_truncated}` — this is what distinguishes a metadata hit from a filename hit in the `files` bucket, and it is identical in shape and meaning to the `matched_fields` returned by `/metadata/search/` (see that endpoint for the full field table and the truncation rules). It may be empty, which is a valid result rather than an error. `matched_fields` and `matched_fields_truncated` are returned only to callers holding **Member** permission on the workspace, exactly as on `/metadata/search/`; below that level the two keys are **absent** from each item rather than empty, and the item's `node_id`, `name`, `parent_id`, `type` and ranking are unaffected. Test for the key's presence, not the list's length. `template_ids` is now ALWAYS EMPTY — templates are retired and the searched corpus records no template association. The key is kept so existing clients keep parsing; it will not be populated again. - **`comments`** — `comment_id`, `entity` (the commented-on node/container), `author_profile_id`, `snippet` (the matching comment text), `reference_type` (the anchor type when the comment is anchored to a position in a file, else `null`), `created`. **Permission model:** Every bucket is permission-filtered against the same rules as that type's dedicated endpoint. Results are produced by reading each match from the source of truth and re-checking the caller's permission **before** any content or count is returned, so search can never reveal an item, a snippet, or even a count for something the caller is not allowed to see. In particular: - **files / metadata** — filtered by the caller's file view permissions (and, on a share, the share's per-item file-view rules). - **comments** — only comments the caller can see in that workspace or share. On a workspace, the comments bucket is **owner-inclusive of File Shares**: it also returns comments left through the workspace's own **live** (active, non-expired, non-revoked) File Shares (a File Share is a view of a workspace file, so the comments belong to the same node). This is one-directional — workspace members see File Share comments, but File Share recipients never see the workspace's internal comments. Share search is unaffected. In rare cases (a workspace with a very large number of File Shares, or a transient error while enumerating them) workspace comment search may temporarily cover only the workspace's own comments; the File Share comments reappear on a later retry. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | `search` missing/blank/too long, a bucket request beyond the supported result window, or an invalid `search_in` / `name_match` / `case_sensitive` value or pattern | | `1609 (Not Found)` | 404 | Search not available for workspace-backed shares (shared folders); share only | | `1680 (Access Denied)` | 401 | No search permission | **Notes:** - Search is not available for workspace-backed shares (shared folders) — the share endpoint returns `404` in that case. - Comments become searchable shortly after they are created or updated (indexing is asynchronous — typically within a few seconds). - A `degraded` bucket is safe to retry; the rest of the response is still valid. - This unified endpoint composes the same per-type searches as `/storage/search/`, `/metadata/search/`, and the comments surface — use it when you want a single grouped result set, or the per-type endpoints when you only need one kind of result. --- ## QuickShare (Workspace Only) > **Deprecated — use File Share.** The `POST` (create / extend-expiry) path now returns **403** (`10756 (Quickshare Deprecated)`) with a directed message pointing to `POST /current/workspace/{workspace_id}/create/fileshare/`. The durable **File Share** (see the next section) replaces it. `GET` (details), `DELETE` (revoke), and the public read endpoints below remain live during the drain so existing links keep serving and can be torn down. ``` POST /current/workspace/{workspace_id}/storage/{node_id}/quickshare/ (deprecated → 403) GET /current/workspace/{workspace_id}/storage/{node_id}/quickshare/ DELETE /current/workspace/{workspace_id}/storage/{node_id}/quickshare/ ``` Retrieve or delete an existing temporary public link for a single file. Creation is deprecated. **Auth required.** Permission: Member. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit workspace profile ID | | `{node_id}` | string | Yes | File OpaqueId | ### POST -- Create or Update QuickShare (deprecated → 403) This path is **deprecated** and returns **403** (`10756 (Quickshare Deprecated)`) for both create and extend-expiry. Use `POST /current/workspace/{workspace_id}/create/fileshare/` (see the File Share section below). The legacy `expires` / `expires_at` request parameters no longer apply because creation is closed. ### GET -- Get QuickShare Details Returns the same format as POST. Returns `1609 (Not Found)` if no quickshare exists. ### DELETE -- Delete QuickShare Returns `{"result": true}`. Returns `1609 (Not Found)` if no quickshare exists. ### Public Access Endpoints (No Auth Required) Once a quickshare is created, these endpoints are accessible without authentication: ``` GET /current/quickshare/{quickshare_id}/details/ -- metadata and file info GET /current/quickshare/{quickshare_id}/storage/read/ -- download the file GET /current/quickshare/{quickshare_id}/storage/readnote/ -- read note content as JSON GET /current/quickshare/{quickshare_id}/storage/preview/{preview_type}/read/ -- preview GET /current/quickshare/{quickshare_id}/storage/preview/{preview_type}/read/file/{filename} -- preview sub-file ``` ### List QuickShares in Workspace ``` GET /current/workspace/{workspace_id}/storage/quickshares/list/ ``` **Auth required.** Permission: Member. Returns an array of all active quickshares in the workspace. **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/quickshares/list/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "quickshares": [ { "id": "qs_abc123def456", "node": { "id": "...", "type": "file", "name": "presentation.pdf" }, "creator_uid": { "id": "...", "email_address": "john@example.com" }, "limit_exceeded": false, "expires": "2025-01-22 10:30:00 UTC", "created": "2025-01-15 10:30:00 UTC" } ] } ``` --- ## File Share Read Endpoints (Public) A **File Share** is the durable successor to QuickShare — a long-lived, link-shareable view of one workspace file. These public read endpoints serve the link viewer. They are anonymous-allowed where the access tier (`anyone_with_link`) permits; for `any_registered` / `named_people` the caller must present a bearer token and have sufficient access. A link password, if set, is presented via the **`x-ve-password` request header** (never in the URL). The bound file is read from the File Share record, so a caller can never substitute a different node id. Bandwidth is metered to the owning organization (no per-link transfer cap). Management endpoints (create / list / update / delete / grants) are in the Workspaces reference. ``` GET /current/fileshare/{fileshare_id}/details/ -- viewer metadata + bound file info GET /current/fileshare/{fileshare_id}/storage/metadata/details/ -- the bound file's metadata pointer: node, template, extraction eligibility; no field values (view capability) GET /current/fileshare/{fileshare_id}/storage/read/ -- download the bound file (download capability) GET /current/fileshare/{fileshare_id}/storage/preview/{preview_type}/read/ -- preview (view capability) GET /current/fileshare/{fileshare_id}/storage/preview/{preview_type}/read/{download_token}/file/{filename} -- preview sub-file GET /current/fileshare/{fileshare_id}/storage/versions/ -- list the bound file's version history (view capability) GET /current/fileshare/{fileshare_id}/storage/versions/{version_id}/read/ -- download a specific version (download capability) ``` ### Details Response ```json { "result": true, "fileshare": { "fileshare": "1234567890123456789", "title": "Quarterly Presentation", "access_option": "anyone_with_link", "has_password": false, "effective_capability": "download", "file": { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "presentation.pdf", "parent": "2ekc7-5efba-yapdo-psqmq-3ntiv-ri56", "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "created": "2026-01-28 10:00:00 UTC", "modified": "2026-01-28 12:30:00 UTC", "size": 5242880, "hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "hash_algo": "sha256", "mimetype": "application/pdf", "mimecategory": "document", "previews": { "pdf": { "state": "ready" }, "thumbnail": { "state": "ready" } }, "virus": { "status": "scanned", "infected": false }, "summary": { "title": "Quarterly Presentation", "short": "Q4 results deck", "long": "..." }, "metadata": { "title": null, "short": null } } } } ``` The bound `file` object carries the **same fields as a workspace file-node detail** (see *Node Details* in this reference): a viewer of the share sees that one file's full info — versioning, previews, summary, metadata, and provenance — exactly as it appears in the workspace. The share-level `effective_capability` (`view` / `download` / `edit`) reflects the highest capability the access tier / grant / password admit for the calling viewer. One field follows `effective_capability`: **embedded file metadata**. `file_attributes.exif_metadata` and `file_attributes.media_metadata` are read out of the file's own bytes, so they are served only to a viewer who may download it. A `view`-only viewer receives `file_attributes` as an empty object `{}` (keys omitted, not `null`, not an error) — on `details`, on `versions`, and on the nested node object the metadata endpoint returns; `download` and `edit` viewers receive the metadata. See *Embedded File Metadata* under *Node Object Schema*. ### Metadata Response `GET /current/fileshare/{fileshare_id}/storage/metadata/details/` identifies the bound file's metadata — which node it is, which template governs it, and whether the file is eligible for automatic extraction. **It returns no metadata field VALUES.** View capability; the node is the bound file (never a caller-supplied node id). ```json { "result": true, "object_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "template_id": "cl4ev-5j54o-cladr-lnyuj-2lcub-gqjm", "node_id": { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "presentation.pdf", "parent": "2ekc7-5efba-yapdo-psqmq-3ntiv-ri56", "mimetype": "application/pdf" }, "autoextractable": true } ``` Those five keys are the entire response — there is no sixth. `template_id` is `null` when the bound file is mapped to no template. Do not code against an `instance_id` here either: the workspace metadata endpoint returns one, this endpoint deliberately does not, because it names the workspace the link was cut from. **No metadata VALUE crosses a File Share link, and that is deliberate.** A link admits anonymous recipients, while the values are the file's own contents and the field names are the owning team's private vocabulary — so this surface serves neither corpus. `metadata_facts` is absent (see *Extracted Metadata Facts* under *Node Object Schema*): absent here, absent on the nested node object this endpoint returns, and absent on every other File Share surface. The legacy `template_metadata` / `custom_metadata` key/value sets are absent too — those blocks have been withdrawn from every Fastio response, on this surface and in the workspace alike. **This is the settled end state, not a temporary restriction**; a recipient sees no metadata values, and nothing is queued to bring them back, so do not build a viewer that waits for them. A workspace member reading the same file through the workspace endpoint gets its facts — see *Get file metadata* in the [AI & Metadata reference](https://api.fast.io/current/llms/ai/). Preview types match the storage preview surface (`thumbnail`, `image`, `pdf`, `mp4`, `hlsstream`, etc.); multi-file previews (e.g. HLS) return a `307 Temporary Redirect` to a sub-file endpoint. Version listing is read-only — there is no restore/promote on the public surface; each entry carries the same per-version fields as a workspace version listing. **Error responses:** | HTTP Status | Code | Cause | |-------------|------|-------| | 406 | `1605 (Invalid Input)` | Invalid File Share id | | 401 | `1650 (Authentication Invalid)` | A link password is required and was missing or wrong (present it via the `x-ve-password` header) | | 403 | `1700 (Forbidden)` | The access tier or named grant does not permit the caller | | 404 | `1609 (Not Found)` | No such File Share, or the bound file content is no longer available | --- ## File Share Note Endpoints (Collaborative Editing) When a File Share's bound node is a **note** (a markdown `.md` node), recipients can read — and, with an edit-capable grant, collaboratively edit — the note through the File Share link. These endpoints back the real-time collaborative editor. The flow is two-step: 1. **Mint a realtime-note token** (`realtime/note-auth`) with a normal signed-in File Share credential. The token is bound to this File Share and this note, and it carries either `view` or `edit` standing. 2. **Read / update the note content** with that token in the `Authorization: Bearer` header. `readnote` and `updatenote` are **token-only** — they accept the realtime-note token, not a workspace user JWT (an external File Share recipient is not a workspace member). The bound note id is fixed on the File Share record; a caller can never substitute a different node id. Bandwidth is metered to the owning organization. ### Mint Realtime-Note Token ``` GET /current/fileshare/{fileshare_id}/realtime/note-auth/{note_id}/ ``` Mint a short-lived realtime-note token for the File Share's bound note. **Auth required.** The caller must be **signed in** (an anonymous `anyone_with_link` visitor cannot mint a token) and must pass the File Share's own access gate (access tier + named grant + link password). A link password, if set, is presented via the **`x-ve-password` request header**. The minted token's standing is capped at the caller's effective capability: an `edit` grant mints an `edit` token (permits `updatenote`); a `view` / `download` grant mints a `view` token (read-only). A read-only scoped access token also caps the result to `view`. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{fileshare_id}` | string | Yes | File Share id (numeric id or opaque id_alt) | | `{note_id}` | string | Yes | OpaqueId of the bound note | **curl example:** ```bash curl -X GET "https://api.fast.io/current/fileshare/1234567890123456789/realtime/note-auth/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/" \ -H "Authorization: Bearer {jwt_token}" \ -H "x-ve-password: {link_password_if_set}" ``` **Response:** ```json { "result": true, "expires_in": 3600, "auth_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `auth_token` | string | The realtime-note bearer token to present to `readnote` / `updatenote` | | `expires_in` | integer | Token lifetime in seconds | **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Missing/invalid File Share id or note id, or the bound node is not a note | | `1609 (Not Found)` | 404 | No such File Share, or the requested id is not the File Share's bound note | | `1680 (Access Denied)` | 401 | Sign-in required (anonymous caller cannot mint a realtime token) | | `1700 (Forbidden)` | 403 | The access tier / named grant / password does not permit the caller, or the presented access token is not scoped to this File Share | | `1650 (Authentication Invalid)` | 401 | Token could not be minted | ### Read Note (File Share) ``` GET /current/fileshare/{fileshare_id}/storage/readnote/{note_id}/ ``` Read the bound note's content as JSON. Returns the sanitized markdown plus the full note resource — the same `{content, note}` shape as the workspace `readnote`. **Token-only.** Present the realtime-note token (from `note-auth`) as the `Authorization: Bearer` credential. There is no session fallback on this surface. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{fileshare_id}` | string | Yes | File Share id (numeric id or opaque id_alt) | | `{note_id}` | string | Yes | OpaqueId of the bound note | **Query parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `version_id` | string | No | Specific version OpaqueId to read | **curl example:** ```bash curl -X GET "https://api.fast.io/current/fileshare/1234567890123456789/storage/readnote/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/" \ -H "Authorization: Bearer {realtime_note_token}" ``` **Response:** ```json { "result": true, "content": "# Meeting Notes\n\nDiscussed project timeline.", "note": { "id": "2ik5q-a43cm-uixi2-van5r-3eolo-7mue", "type": "note", "name": "meeting-notes.md", "parent": "root", "mimetype": "text/markdown", "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "created": "2026-07-07 10:30:00 UTC", "modified": "2026-07-07 10:30:00 UTC" } } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1650 (Authentication Invalid)` | 401 | Realtime-note token missing, invalid, or expired | | `1651 (Invalid Method)` | 405 | Only `GET` is accepted | | `1605 (Invalid Input)` | 406 | Invalid File Share id, note id, or version id | | `1700 (Forbidden)` | 403 | Token is not bound to this File Share/note, the File Share is unavailable, or the token lacks read capability | | `1609 (Not Found)` | 404 | Bound note no longer exists | | `1680 (Access Denied)` | 401 | The note (or requested version) is blocked from serving — virus-infected, DMCA-flagged, or restricted (an edit lock does not block reads) | | `1654 (Internal Error)` | 500 | Failed to retrieve the note | ### Update Note (File Share) ``` POST /current/fileshare/{fileshare_id}/storage/updatenote/{note_id}/ ``` Replace the bound note's markdown content and/or rename it. Updating content creates a new version. Returns the full `{note}` resource — the same shape as the workspace `updatenote`. **Conflict response (`if_version_id` mismatch)** — identical to the workspace `updatenote` conflict documented above: HTTP `409`, `error.params` is a **list of entries**, and the conflict entry carries `name: "if_version_id"`, `kind: "conflict"`, `reason: "conflict_version_mismatch"`, and `current_version_id` (also appended to `message` as ` current_version_id=`). Branch on `reason` — the only field naming the cause. `name` + `kind` is a fallback for clients that receive only the four standard fields: it proves *a* precondition on that parameter failed, not which one, so treat it as a generic conflict; never on the `409` status alone or on the numeric code. ⚠️ `error.params` is **not** an object — a client reading `error.params.current` receives nothing. **Token-only.** Present a realtime-note token that carries **edit** standing (a `view` token is rejected `403`). `name` and `content` must be supplied in the POST body (they are body-only — a write field in the query string is rejected). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{fileshare_id}` | string | Yes | File Share id (numeric id or opaque id_alt) | | `{note_id}` | string | Yes | OpaqueId of the bound note | **Request body (form-encoded):** | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `name` | string | No | 1-255 characters (counted as characters, not bytes); must end in `.md` | New note name | | `content` | string | No | Max 100 KB, non-blank | New markdown content (empty/whitespace-only is rejected) | | `if_version_id` | string | No | Version OpaqueId | Compare-and-swap precondition — the update proceeds only if the note's current `version` matches; otherwise `409 Conflict` with no change | At least one of `name` or `content` is required. **curl example:** ```bash curl -X POST "https://api.fast.io/current/fileshare/1234567890123456789/storage/updatenote/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/" \ -H "Authorization: Bearer {realtime_note_edit_token}" \ -d 'content=# Updated Notes\n\nRevised content here.' ``` **Response:** ```json { "result": true, "note": { "id": "2ik5q-a43cm-uixi2-van5r-3eolo-7mue", "type": "note", "name": "meeting-notes.md", "parent": "root", "mimetype": "text/markdown", "version": "3ak5n-dr47a-qnylo-kzv6c-e6bnm-3u3c", "created": "2026-07-07 10:30:00 UTC", "modified": "2026-07-07 14:45:00 UTC" } } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1650 (Authentication Invalid)` | 401 | Realtime-note token missing, invalid, or expired | | `1651 (Invalid Method)` | 405 | Only `POST` is accepted | | `1605 (Invalid Input)` | 406 | Invalid File Share id / note id, `name` not ending in `.md`, blank content, or malformed markdown / version id | | `1700 (Forbidden)` | 403 | Token is not bound to this File Share/note, the File Share is unavailable, or the token lacks edit capability (a view token cannot update) | | `1609 (Not Found)` | 404 | Bound note no longer exists | | `113958` | 409 | `if_version_id` did not match the note's current version (no change made). `error.params[]` carries the conflict entry — see *Conflict response* above. (`1660` is not the value of `error.code`.) | | `1680 (Access Denied)` | 401 | The note is blocked from writing — virus-infected, DMCA-flagged, or restricted | | `1654 (Internal Error)` | 500 | Failed to retrieve or update the note | --- ## File Locking Lock a file to prevent concurrent edits. Locks expire automatically if not renewed via heartbeat. Available on both workspace and share storage. ### Acquire Lock ``` POST /current/workspace/{workspace_id}/storage/{node_id}/lock/ POST /current/share/{share_id}/storage/{node_id}/lock/ ``` **Auth required.** Permission: Guest (workspace), file modification permission (share). **Request body (form-encoded):** | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `duration` | integer | No | 60-3600 seconds | Lock duration (default varies) | | `client_info` | string | No | JSON object | Client metadata: `device_name` (max 255), `client_version` (max 50) | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/lock/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'duration=300' \ -d 'client_info={"device_name":"My Laptop","client_version":"2.1.0"}' ``` **Response:** ```json { "result": true, "lock_token": "unique_lock_token_string", "locked_at": "2025-01-28 10:00:00 UTC", "expires_at": "2025-01-28 10:05:00 UTC", "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4" } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `lock_token` | string | Token required for heartbeat and release operations | | `locked_at` | string | Lock acquisition time (`YYYY-MM-DD HH:MM:SS UTC`) | | `expires_at` | string | Lock expiration time (`YYYY-MM-DD HH:MM:SS UTC`) | | `node_id` | string | OpaqueId of the locked node | **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found or outside your workspace/share scope | | `1609 (Not Found)` | 404 | Cannot lock a deleted node (workspace only) | | `1660 (Conflict)` | 409 | Node already locked by another user | | `1680 (Access Denied)` | 401 | Insufficient permission to acquire a lock on this node (share only) | | `1693 (Temporarily Unavailable)` | 503 | Lock service momentarily unavailable; retry after a brief delay | ### Heartbeat (Extend Lock) ``` POST /current/workspace/{workspace_id}/storage/{node_id}/lock/heartbeat/ POST /current/share/{share_id}/storage/{node_id}/lock/heartbeat/ ``` **Auth required.** Permission: Guest (workspace), file modification permission (share). **Request body (form-encoded):** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `lock_token` | string | Yes | Token from acquire response | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/lock/heartbeat/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'lock_token=unique_lock_token_string' ``` **Response:** ```json { "result": true, "expires_at": "2025-01-28 10:10:00 UTC", "time_remaining": 300 } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | No lock exists on this node | | `1609 (Not Found)` | 404 | Lock has expired | | `1609 (Not Found)` | 404 | Node not found or outside your workspace/share scope | | `1660 (Conflict)` | 409 | Recreation race: another caller acquired the lock first; re-acquire and retry | | `1680 (Access Denied)` | 401 | Lock token does not match | | `1680 (Access Denied)` | 401 | **The lock was taken over by another user.** Stop editing and re-read the file — this is not an expiry and re-acquiring would discard the fact that somebody took the file from you | | `1680 (Access Denied)` | 401 | Insufficient permission to heartbeat this lock (share only) | | `1693 (Temporarily Unavailable)` | 503 | Lock service momentarily unavailable; retry after a brief delay | **Notes:** - **A heartbeat renews the lock for the same `duration` it was acquired with.** A lock taken for an hour is renewed for another hour, not shortened to some fixed amount. The renewal REPLACES the time still remaining rather than adding to it, so `expires_at` always comes back as roughly now plus that duration. - Send heartbeats well before the lock expires (e.g., at 50% of lock duration). - **Heartbeats can recreate a lock that has already expired — the `lock_token` is what is checked.** Any authorized caller presenting the correct token recreates it, and the recreated lock is then held by that caller, so treat the token as a secret. A recreated lock starts a fresh lease at the service default duration, NOT at the duration the original lock was acquired with — the expired lock is gone and its duration cannot be recovered, and this endpoint accepts no `duration`. Read `expires_at` from the response rather than assuming, and re-acquire with an explicit `duration` if you need a longer lease again. - **Branch on the refusal, don't just retry.** `1609 (Not Found)` means the lock lapsed and you may re-acquire it and carry on. `1680 (Access Denied)` means you no longer hold it — either the token is wrong or somebody overrode you — so re-read the file before doing anything else. Treating the second as the first is how two people end up editing the same file. ### Release Lock ``` DELETE /current/workspace/{workspace_id}/storage/{node_id}/lock/ DELETE /current/share/{share_id}/storage/{node_id}/lock/ ``` **Auth required.** Permission: Guest (workspace), file modification permission (share). **Request body/query:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `lock_token` | string | Yes | Token from acquire response | **Response:** ```json { "result": true, "released": true } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | No lock exists on this node | | `1609 (Not Found)` | 404 | Node not found or outside your workspace/share scope | | `1680 (Access Denied)` | 401 | Lock token does not match | | `1680 (Access Denied)` | 401 | Insufficient permission to release this lock (share only) | | `1693 (Temporarily Unavailable)` | 503 | Lock service momentarily unavailable; retry after a brief delay | ### Lock Status ``` GET /current/workspace/{workspace_id}/storage/{node_id}/lock/ GET /current/share/{share_id}/storage/{node_id}/lock/ ``` **Auth required.** Permission: Guest (workspace), file view permission (share). **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found or outside your workspace/share scope | | `1680 (Access Denied)` | 401 | Insufficient permission to view lock status (share only) | **The holder's identity requires MEMBER level or above.** `locked` and `node_id` are returned to every caller who may reach the endpoint — a client that cannot see them will offer a busy node as editable. The remaining fields (`locker_uid`, `locked_at`, `expires_at`, `locker`, and `time_remaining` on workspaces) identify *who* holds the lock and *when they started and stopped working*, and are returned only to callers at member level or above. Below that — workspace guests, share guests, public-link recipients — those keys are **absent**, exactly as `lock_info` is `null` on the node object for the same callers. Read `locked` for occupancy and treat the identity keys as optional; do not infer "unlocked" from a missing `locker_uid`. `locker.agent_name` names the agent that took the lock on the holder's behalf, when one did, and `locker.agent_name_source` says where that name came from; both are `null` when a person took the lock directly. **`agent_name` is self-declared, not verified** -- display it beside the holder, never rely on it to identify or authorize anyone. 🔴 **The name belongs to the CREDENTIAL, not to the lock.** It is read from the credential that took the lock -- a JWT claim (`agent_name_source: "jwt_claim"`) or the API key's own label (`"api_key_label"`) -- and it is set once, when you sign in or mint the key. **There is no per-lock parameter for it**, and nothing you send at acquire time can set, override, or suppress it. Two consequences worth designing around: every lock taken by one credential carries the same label, and a credential with no agent name produces `null` on every lock it takes, forever, until the credential itself is changed. If you want a lock to show an agent name, that decision happens at sign-in or key creation -- not at the lock call. This block carries no `display_name`; read the node object's `lock_info.locker` when you need the holder's name. **Response (locked, member or above):** ```json { "result": true, "locked": true, "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "locked_at": "2025-01-28 10:00:00 UTC", "expires_at": "2025-01-28 10:05:00 UTC", "time_remaining": 245, "locker_uid": "1234567890123456789", "locker": { "agent_name": "Claude-2", "agent_name_source": "api_key_label" } } ``` **Response (locked, below member):** ```json { "result": true, "locked": true, "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4" } ``` **Response (unlocked):** ```json { "result": true, "locked": false } ``` ### Override Lock ``` POST /current/workspace/{workspace_id}/storage/{node_id}/lock/override/ POST /current/share/{share_id}/storage/{node_id}/lock/override/ ``` **Auth required.** Permission: Guest (workspace), file modification permission (share) — the same bar as writing the file. Takes the lock over from whoever holds it, without their `lock_token`, and gives it to you. Use it when a collaborator left a file locked and is no longer editing. Anyone who can write the file can override its lock: the lock is advisory and expires on its own, so it never granted exclusive write rights, and requiring more than write access to take it over would protect nothing. **This is a TAKEOVER, not a release — you end up holding the lock.** The response carries a new `lock_token` that is yours: heartbeat it and release it exactly as if you had acquired it. The displaced holder's token stops working immediately; their next heartbeat is refused with `1680 (Access Denied)`, which is how their client learns a person took the file rather than that their own lock lapsed. That refusal holds even after you release the lock again — for the remainder of the lifetime their own lock had left — so a slow or paused client cannot quietly pick the file back up. **Overriding does NOT let you overwrite someone else's changes, and must not be presented to a user as "force save".** A lock and a version precondition are different protections. The override takes the lock; it does not waive `if_version_id`. An update sent afterwards with a stale `if_version_id` still fails with the same `409` conflict it would have returned before the override — see *Conflict response*. To save after an override, re-read the file, rebase onto the current `version_id`, and send that. Overriding a file that is not locked also succeeds — you simply acquire it, and `overridden` is `false`. Overriding a lock **you already hold** is also safe: you keep your existing `lock_token`, nothing is displaced, and your in-flight heartbeat keeps working. A retry is therefore harmless. Every override is recorded as an event on the owning workspace or share, naming who overrode and which file, and is readable through the events API. The displaced holder is deliberately NOT named in that event: their identity requires member level, while the event itself is readable one tier below that, so it is recorded internally rather than published. **No request body.** **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/lock/override/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "lock_token": "unique_lock_token_string", "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "locked_at": "2025-01-28 10:03:12 UTC", "expires_at": "2025-01-28 10:08:12 UTC", "overridden": true, "previous_locker_uid": "1234567890123456789" } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `lock_token` | string | YOUR new token for the lock you now hold — required for heartbeat and release | | `node_id` | string | OpaqueId of the node | | `locked_at` | string | When you took the lock (`YYYY-MM-DD HH:MM:SS UTC`) | | `expires_at` | string | When your lock expires unless renewed (`YYYY-MM-DD HH:MM:SS UTC`) | | `overridden` | boolean | `true` when a live lock was actually replaced; `false` when the file was not locked, or when you already held it. It reports whether somebody was interrupted, which is not the same question as whether we can name them — it stays `true` even when `previous_locker_uid` is `null` | | `previous_locker_uid` | string/null | Who held the lock, or `null` when nobody did (or when the holder could not be identified). Always a **string**, never a JSON number — ids exceed JavaScript's safe integer range. **Requires MEMBER level or above**, exactly as `locker_uid` on lock status does; below that the key is **absent**. Read `overridden` to learn whether anyone was displaced — that stays truthful for every caller | **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found or outside your workspace/share scope | | `1680 (Access Denied)` | 401 | Insufficient permission to modify locks on this node (share only) | | `1693 (Temporarily Unavailable)` | 503 | Lock service momentarily unavailable; retry after a brief delay | **Notes:** - After overriding, heartbeat the returned `lock_token` on the usual schedule — it is an ordinary lock with an ordinary expiry. - A client that has its heartbeat refused with `1680 (Access Denied)` should stop editing and re-read the file: someone took the lock. That is different from `1609 (Not Found)` on heartbeat, which means the lock lapsed and can be re-acquired. --- ## Previews File previews provide rendered views of documents, images, video, and other content without downloading the original file. ### Preview Types | Value | Description | |-------|--------------------------------------| | `thumbnail` | Small thumbnail image | | `image` | Full-size image preview | | `mp4` | MP4 video preview (transcoded) | | `hlsstream` | HLS video/audio stream | | `audio` | Audio preview (transcoded) | | `pdf` | PDF document preview | | `spreadsheet` | Spreadsheet preview | | `bin` | Binary preview (raw bytes for clients that render their own preview) | ### Preview States Returned in node details responses under `previews.{type}.state`: | State | Description | |-----------------|--------------------------------------| | `unknown` | Preview status not yet determined | | `not possible` | File type cannot be previewed | | `not generated` | Preview not yet generated | | `error` | Preview generation failed | | `in progress` | Preview is being generated | | `ready` | Preview is available | ### Preauthorize Preview ``` GET /current/workspace/{workspace_id}/storage/{node_id}/preview/{preview_type}/preauthorize/ GET /current/share/{share_id}/storage/{node_id}/preview/{preview_type}/preauthorize/ ``` Get a preview download URL with an embedded token. **Auth required.** Permission: View (workspace), file view permission (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{preview_type}` | string | Yes | One of: `thumbnail`, `image`, `mp4`, `hlsstream`, `audio`, `pdf`, `spreadsheet`, `bin` | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/preview/thumbnail/preauthorize/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "downloadToken": "eyJhbGciOiJIUzI1NiJ9...", "path": "/current/workspace/1234567890123456789/storage/2ltsu-q4mja.../preview/thumbnail/read/eyJhbGci.../file/preview.png", "primaryFilename": "preview.png" } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `downloadToken` | string | JWT token for preview access | | `path` | string | Full API path to read the preview file | | `primaryFilename` | string | Name of the primary preview file | **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | File not found | | `1605 (Invalid Input)` | 406 | Can only preview file or note | | `1609 (Not Found)` | 404 | File is in trash | | `1652 (Resource Not Found)` | 404 | File content is no longer available | | `1652 (Resource Not Found)` | 404 | Preview not available | ### Read Preview ``` GET /current/workspace/{workspace_id}/storage/{node_id}/preview/{preview_type}/read/ GET /current/share/{share_id}/storage/{node_id}/preview/{preview_type}/read/ ``` Read or redirect to a preview. For single-file previews, streams content directly. For multi-file previews, returns a `307 Temporary Redirect` to the file-specific endpoint with a generated token. If the source file is corrupt, truncated, or otherwise unreadable by the render pipeline, returns HTTP `422 Unprocessable Entity`. Clients should not retry — the source file itself is the problem. **Auth required.** ### Token-Based Preview Read ``` GET /current/workspace/{workspace_id}/storage/{node_id}/preview/{preview_type}/read/{token}/file/{filename} GET /current/share/{share_id}/storage/{node_id}/preview/{preview_type}/read/{download_token}/file/{filename} ``` Read a specific preview file using a token (from preauthorize). **Token-based auth -- no Authorization header needed.** **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{token}` | string | Yes | Download token from preauthorize | | `{filename}` | string | Yes | Preview filename | **Range requests are supported**, with the same semantics as the file read endpoint above: `206 Partial Content` with `Content-Range` for a satisfiable range, `416` for a range that starts at or past the end, `200` with the whole body for an unparseable `Range` header, and `GET` only (`HEAD` returns `405`). The download token is **not single-use** — it stays valid for its lifetime, so a client may issue many ranged requests against the same URL. Note that the size being ranged over is the **preview artifact's**, which is not the source file's size unless the preview is served as the original (as it is for a PDF preview of a PDF). ### Request Preview Nonce (Share Only) ``` GET /current/share/{share_id}/storage/{node_id}/requestpreview/ ``` Mint a one-time nonce that lets a caller read a file it is not allowed to download. **Shares with `download_security=medium` only** — any other security level is rejected. This is how a share that withholds downloads still lets a viewer see the file: the nonce authorizes a single read and nothing else. **Auth required.** File-view permission on the share. **Response (200 OK):** ```json { "result": true, "preview_nonce": "abc123..." } ``` Pass the value back as the `preview_nonce` parameter on `GET /current/share/{share_id}/storage/{node_id}/read/`. It is **single-use and short-lived** — 60 seconds, consumed on first use — so mint it at the moment of the read, not ahead of time, and mint a fresh one per read. It is bound to the node it was issued for and is not valid for any other file. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `125621` | 406 | The share's download security is not `medium` | | `164543` | 406 | The node is a folder — only a file or note can be previewed | | `180409` | 404 | Node not found | | `160750` | 404 | The node exists but is outside this share | | `173838` | 404 | The node is in the trash | --- ## Transforms Image transforms allow on-the-fly resizing, cropping, rotating, and format conversion. ### Get Transform Status ``` GET /current/workspace/{workspace_id}/storage/{node_id}/transform/{transform_name}/ GET /current/share/{share_id}/storage/{node_id}/transform/{transform_name}/ ``` Check if a transformation is available without triggering it. **Auth required.** Currently the supported `{transform_name}` is `image`. **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/transform/image/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "state": "rendered" } ``` ### Transformation States | State | Description | |--------------------|------------------------------| | `rendered` | Transform is ready | | `rendering` | Transform in progress | | `unrendered` | Transform not yet requested | | `unable to render` | Transform failed or unsupported | ### Request Transform ``` POST /current/workspace/{workspace_id}/storage/{node_id}/transform/{transform_name}/request/ POST /current/share/{share_id}/storage/{node_id}/transform/{transform_name}/request/ ``` Request a transformation. If not yet rendered, triggers the transformation. If already rendered, returns immediately. **Auth required.** **Response:** ```json { "result": true, "state": "rendered" } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Unknown transformation name | | `1609 (Not Found)` | 404 | Unable to transform (failed or unsupported) | ### Read Transformed File ``` GET /current/workspace/{workspace_id}/storage/{node_id}/transform/{transform_name}/read/ GET /current/share/{share_id}/storage/{node_id}/transform/{transform_name}/read/ ``` Download the transformed file. Supports byte-range requests and token auth. **Auth: JWT or download token.** ### Image Transform Parameters Pass as query parameters on transform read endpoints: | Parameter | Type | Values | |-----------------|--------|-----------------------------------------------| | `output-format` | string | `png`, `jpg`, `jpeg` -- **required** | | `width` | int | Target width in pixels | | `height` | int | Target height in pixels | | `cropwidth` | int | Crop region width | | `cropheight` | int | Crop region height | | `cropx` | int | Crop region X offset | | `cropy` | int | Crop region Y offset | | `rotate` | int | `0`, `90`, `180`, `270` | | `size` | string | Predefined: `IconTiny`, `IconSmall`, `IconMedium`, `Preview` | `output-format` is required on every transform read; the rest are optional. Send `OPTIONS` to a transform read URL for the accepted parameter list as the API itself reports it. **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/transform/image/read/?width=200&height=200&output-format=jpg" \ -H "Authorization: Bearer {jwt_token}" \ -o thumbnail.jpg ``` ### Request Transform Download Token ``` GET /current/workspace/{workspace_id}/storage/{node_id}/transform/{transform_name}/requestread/ GET /current/share/{share_id}/storage/{node_id}/transform/{transform_name}/requestread/ ``` Get a temporary download token for the transformed file. Available on both workspaces and shares. **Auth required.** **Response:** ```json { "result": true, "token": "eyJhbGciOiJIUzI1NiJ9..." } ``` --- ## Download Tokens Pattern The `requestread` endpoint generates temporary, auth-free download tokens for files, previews, and transforms. **Flow:** 1. `GET .../storage/{node_id}/requestread/` -- returns `{"token": "..."}` 2. `GET .../storage/{node_id}/read/?token={token}` -- download without Authorization header Useful for opening files in browser tabs or embedding in pages without exposing auth headers. In medium security mode, file previews are available for guests but direct downloads are restricted. Owners and admins can still download normally. --- ## Saved metadata filters The per-user saved-view endpoints (`metadata/view/`, `metadata/views/`) have been REMOVED. They are replaced by workspace-shared **saved filters** — a named predicate over extracted metadata plus an optional display projection. ``` POST /current/workspace/{workspace_id}/metadata/filters/ GET /current/workspace/{workspace_id}/metadata/filters/ GET /current/workspace/{workspace_id}/metadata/filters/{filter_id}/ PUT /current/workspace/{workspace_id}/metadata/filters/{filter_id}/ DELETE /current/workspace/{workspace_id}/metadata/filters/{filter_id}/ GET /current/workspace/{workspace_id}/metadata/filters/{filter_id}/nodes/ ``` **Auth:** Bearer token required. Workspace member. Metadata billing feature required. Create and update take a **JSON object request body**; list and execute take query-string parameters. Create and update both require `name` and `predicate` (send `[]` for match-all), and **`PUT` replaces the whole filter** — an omitted `description` or `projection` is cleared, so read before you write. Create, get and update answer `{"result": true, "filter": …}`; list answers `{"result": true, "count", "items", "cursor", "has_more"}` — note `items`, not `filters`; delete answers `{"result": true}` and is idempotent, so a `200` does not prove anything was deleted. Full reference — the request/response contract per endpoint, predicate operators, the clause cap, projection semantics and the `scope` object — is in the AI & Metadata docs under *Saved metadata filters*. --- ## Workspace-Only Features These endpoints are only available on workspaces, not shares: - `addlink` -- add a share link to storage - `createnote` / `updatenote` -- markdown note creation and editing - `readnote` is available on both workspaces and shares (shares support token-based access) - `quickshare` -- temporary public file links (creation **deprecated** → use File Share) - `quickshares/list` -- list all quickshares - `create/fileshare` / `list/fileshares` -- durable single-file File Share management (replaces QuickShare) ## Share-Specific Notes - Share storage follows identical patterns to workspace storage for all common operations - Shares support file locking (acquire, heartbeat, release, status, override) - Shares support previews and transforms (status, request, read, requestread) - Shares additionally support `requestpreview` (one-time preview nonce) for `download_security=medium` shares - Share permissions are granular: separate permissions for file view, download, creation, modification, and administration - Embedded file metadata (`file_attributes.exif_metadata` / `file_attributes.media_metadata`) is returned only to callers permitted to download the file -- when downloads are not permitted the keys are omitted and `file_attributes` is `{}` (see *Embedded File Metadata* under *Node Object Schema*) - Shares may restrict operations to files the user created (creator-only restrictions) - Workspace folder shares map `root` to the designated folder and scope all operations to that subtree - Search is not available for workspace folder shares (files are indexed by workspace, not share) - Inventory (`/storage/inventory/`) is not available for workspace folder shares -- enumerate the backing workspace instead - Public shares may allow listing and downloading without JWT authentication