AI & Chat RAG-powered chat, semantic search, notes, metadata extraction, and auto-summarization

Base URL: https://api.fast.io/current/ Auth: Bearer {jwt_token}

AI agent endpoint paths (/ai/agent/): the AI agent endpoints are served under /ai/agent/, which is the only path family. The older /ai/chat/ paths have been retired and removed — they no longer respond, and there is no alias resolving them to the agent handlers.

If you have an older integration still calling /ai/chat/..., update it to the equivalent /ai/agent/... path. The core request and response shapes carry over, but file attachment changed: the old files_attach string parameter is gone, files and folders are now attached as reference items, and a file that cannot be attached returns an error instead of being silently ignored. See Attaching Files and Folders below.

Overview

Fastio provides built-in AI capabilities for workspaces and shares:

Fastio's AI is a full agent, not a read-only Q&A tool. In addition to reading, analyzing, searching, and answering questions about your files, it can take actions on your behalf — for example, creating documents and notes and organizing your content. It always acts within your own permissions and plan entitlements, and its actions consume credits like any other operation.

Plan Requirements

AI capabilities are gated by a set of billing-plan features:

Plan coverage:

Plancontent_aiai_agentEffect
StarterononFull AI (chat, intelligence, RAG)
BusinessononFull AI (chat, intelligence, RAG)
GrowthononFull AI (chat, intelligence, RAG)

All current paid plans (Starter, Business, and Growth) include both content_ai and ai_agent, so full AI — chat, intelligence, and RAG — is available on every plan a new organization can choose. New organizations choose one of these paid plans.

How gated endpoints respond when a plan lacks the feature:

New organizations — for humans and AI agents alike — choose a paid plan (Starter, Business, or Growth), each of which includes both content_ai and ai_agent and unlocks full agentic chat, intelligence, and RAG. See the Organizations reference for plan selection.

Intelligence Setting

The intelligence boolean on a workspace or portal share controls whether uploaded files are automatically indexed for RAG.

Shared folder restriction: Intelligence is only available on portal shares (independent storage). Workspace folder shares (storage_mode=workspace_folder) cannot have intelligence enabled — their files are indexed through the parent workspace instead. The API will return an error if you attempt to enable intelligence on a shared folder share.

At creation:

POST /current/org/{org_id}/create/workspace/

Optional intelligence=true|false; defaults to true, clamped off by plan

Change it later:

POST /current/workspace/{workspace_id}/update/

Pass intelligence=true|false

Note: Intelligence can be enabled and disabled within time restrictions. Disabling intelligence destroys indexed embeddings (the vector index is flushed). Re-enabling intelligence incurs re-indexing costs as AI credits are consumed to re-index all files. When a plan loses ai_agent (e.g. on downgrade), the RAG indexing pipeline stops even if the instance flag remains set; previously indexed embeddings remain but will not be updated.

How the Agent Uses Files

There is no chat “type” to choose — a single agent surface handles every conversation. You do not pass a type (or personality) parameter; the agent adapts to what you send:

A single agent turn can combine all of the above — general reasoning, RAG over the indexed scope, and directly attached files.

Attaching Files and Folders

Give the agent specific files or folders as context by including them as reference items in the references, content_parts, or subjects array of a create-chat or send-message request. You do not assemble a file’s metadata yourself — send only the node id (and, for a file, an optional version id), and Fastio resolves the full details server-side after verifying your access and the file’s AI-readiness.

subjects pins objects from inside the workspace/share as the turn’s focus; a separate uploads array carries files staged from outside the scope. content_parts is an ordered stream that interleaves text segments with inline reference pills (the same item shape). All three of references, content_parts, and subjects are resolved and gated by the rules below.

File reference

{ "type": "file", "id": "{node_id}", "file_details": { "node_id": "{node_id}", "version_id": "{version_id}" } }

Folder reference

{ "type": "folder", "id": "{node_id}", "folder_details": { "node_id": "{node_id}" } }

Limits

Validation is strict — a bad reference fails the request

If a referenced file or folder cannot be attached — it does not exist, you cannot access it, it has been deleted, or it is not AI-eligible — the request is rejected with an error, not silently dropped:

Choosing what to attach

Use CaseWhat to attach
Analyze specific files directlyFile reference items in references / content_parts / subjects
Ground answers in a folder’s indexed files (with citations)Folder reference items (requires intelligence enabled)
Ask general questions across all indexed filesNothing — the agent may search the whole indexed scope
General conversation, no filesNothing

AI State (File Readiness)

Files in an intelligent workspace progress through AI processing states:

StateMeaning
disabledIntelligence not enabled for this file/workspace
pendingQueued for AI processing
in_progressCurrently being processed by AI
readyFile can be used with AI chat (attached directly or via scope). The file has been processed enough (e.g., preview/summary generated) to be usable in AI conversations.
indexedFile contents (for documents) have been indexed via RAG. This state is used when intelligence is enabled on the workspace/share. Indexed files are searchable by semantic meaning and their content is used as grounding in scoped AI chats.
failedAI processing failed

Files with ai_state: ready can be used with AI chat. Files with ai_state: indexed have additionally had their contents indexed for RAG-powered semantic search. When intelligence is enabled on a workspace/share, files progress to indexed automatically. Check a file’s AI state in the ai.state field of storage list or file details responses.

Controlling Response Length and Style

There is no personality parameter. Control verbosity and style directly in your question phrasing:

Advanced Per-Turn Fields

Beyond question and the file-reference arrays, a create-chat or send-message request accepts these optional per-turn fields. All are optional; omit them for normal use.

FieldTypeDescription
uploadsJSON arrayFocus files staged from outside the workspace/share (as opposed to subjects, which pins objects from inside it).
viewJSON objectA snapshot of the caller’s current UI view, so the agent can reason about what the user is looking at.
activityJSON arrayRecent-activity entries giving the agent short-term context.
role_in_orgstringThe acting user’s free-text role in the organization, used to tailor the response.
idempotency_keystringClient-supplied replay guard (max 64 chars). Re-sending the same key returns the already-created turn instead of creating a duplicate. Omit to have one generated.

The acting user’s identity is taken from your Bearer token — it is never read from the request body, so a request cannot spoof who the turn runs as.

Notes (Stored Knowledge)

Notes are a storage node type (like files and folders) that store markdown content directly on the server. They appear in storage listings with "type": "note" and "mimetype": "text/markdown". Notes are workspace-only — they cannot be created in shares.

Why Notes Matter

In an intelligent workspace, notes are ingested and indexed just like uploaded files. This makes them a way to bank knowledge over time — store interesting facts, research findings, decisions, or project context. In future AI chats that scope the entire workspace (or include the note’s folder), the note content will be used as grounding when the AI searches for relevant information.

Create a note

POST /current/workspace/{workspace_id}/storage/{parent_id}/createnote/
ParameterTypeRequiredDescription
namestringYesNote name, must end in .md
contentstringYesMarkdown content, max 100 KB

{parent_id} is a folder OpaqueId or "root". Returns the created note as a node resource.

Update a note

POST /current/workspace/{workspace_id}/storage/{node_id}/updatenote/
ParameterTypeRequiredDescription
namestringNoNew name, must end in .md
contentstringNoNew markdown content, max 100 KB, non-blank (an empty or whitespace-only value is rejected)

At least one of name or content must be provided. Updating content creates a new version.

Read note content

GET /current/workspace/{workspace_id}/storage/{node_id}/read/

Returns the raw markdown content.

Linking a user to a note

Workspace AI Endpoints

Create a new chat

POST /current/workspace/{workspace_id}/ai/agent/

Creates a thread and its first turn; the AI processes it asynchronously. There is no type or personality parameter — the request body is the initial question plus optional file references and the thread create-time fields.

Auth: Bearer token required. Workspace view permission. content_ai and ai_agent plan features required.

ParameterTypeRequiredDefaultDescription
questionstringYesInitial question, 1–32,000 characters. (May be omitted only when content_parts carries the message text.)
privacystringNoprivateprivate or public. public is currently disabled platform-wide — a privacy=public request returns 403 Forbidden; see “Publish a private chat” below.
namestringNoAuto-generatedChat name. A default is used if omitted.
kindstringNouseruser or agent. agent flags the chat as agentic. Set at creation, immutable thereafter.
referencesJSON arrayNoFile/folder reference items to attach as context — each a {type, id} file or folder item (see Attaching Files and Folders). Up to 20 files / 2 GB / 100 references; the backend resolves each item’s full details server-side.
content_partsJSON arrayNoOrdered content stream — text segments plus inline file/folder reference pills (same item shape as references).
subjectsJSON arrayNoFile/folder reference items pinned as focus subjects for the turn (same item shape as references).
uploadsJSON arrayNoFocus files staged from outside the workspace.

Also accepts the optional view, activity, role_in_org, and idempotency_key fields (see Advanced Per-Turn Fields above).

Request example:

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d "question=What were the Q3 revenue figures?" \
  -d "privacy=private"

Response (200 OK):

{
  "result": true,
  "thread": {
    "thread_id": "aBcDeFgHiJkLmNoPqR",
    "creator": { "type": "user", "id": "1234567890123456789" },
    "scope": { "type": "workspace", "id": "1234567890123456789" },
    "name": "New Chat",
    "status": "active",
    "kind": "user",
    "cost": { "credits": 0, "tokens": 0 },
    "privacy": { "visibility": "private", "owner": { "type": "user", "id": "1234567890123456789" } },
    "created_at": "2026-07-07 16:37:29 UTC",
    "updated_at": "2026-07-07 16:37:29 UTC"
  },
  "turn": {
    "turn_id": "xYzAbCdEfGhIjKlMnO",
    "thread_id": "aBcDeFgHiJkLmNoPqR",
    "seq": 1,
    "status": "pending",
    "idempotency_key": "3f2a…",
    "query": { "text": "What were the Q3 revenue figures?" },
    "error": null,
    "cost": { "credits": 0, "tokens": 0 },
    "created_at": "2026-07-07 16:37:29 UTC",
    "updated_at": "2026-07-07 16:37:29 UTC"
  }
}
FieldTypeDescription
thread.thread_idstringOpaque ID of the created thread (the chat). Use it as {chat_id} in the follow-up URLs below.
turn.turn_idstringOpaque ID of the initial turn (the first message). Use it as the {message_id} in the message-details / read URLs.
turn.statusstringInitial turn status — pending. The AI processes it asynchronously (see turn statuses under “Get message details”).

The full field lists are in Chat Session Object Schema (thread) and Message Object Schema (turn) below.

Error responses:

Reading the error tables: the four-digit 16xx/17xx values below 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. Five- and six-digit codes (and the 9661-9669 family) are real 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.

Error CodeHTTP StatusCause
1605 (Invalid Input)406Invalid privacy, kind, or name, or invalid question length
1609 (Not Found)404An attached file or folder reference does not exist or is not accessible
1605 (Invalid Input)406An attached reference is malformed, the wrong node type, or exceeds the 20-file / 2 GB / 100-reference limit
1700 (Forbidden)403privacy=public requested while public chats are disabled platform-wide
1660 (Conflict)409Thread still committing its first turn (retry with the same idempotency key), or the first message is too large to process
1664 (Datastore Error)500Thread or turn creation failed

List chats

GET /current/workspace/{workspace_id}/ai/agent/list/

Returns all chats created by the current user in the workspace. Sorted by most recently modified first.

Auth: Bearer token required. Workspace view permission. content_ai plan feature required.

Query parameters:

ParameterTypeRequiredDefaultDescription
kindstringNouserFilter by chat kind. Allowed values: user (only user-driven chats — the historical default), agent (only agentic chats), all (user + agent chats). Omit or pass user for backwards-compatible behavior.

Variant: Append /deleted to the path to list deleted chats: GET .../ai/agent/list/deleted

Request example:

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/list/" \
  -H "Authorization: Bearer {jwt_token}"

Response (200 OK):

{
  "result": true,
  "chats": {
    "count": 1,
    "items": [
      {
        "thread_id": "aBcDeFgHiJkLmNoPqR",
        "creator": { "type": "user", "id": "1234567890123456789" },
        "scope": { "type": "workspace", "id": "1234567890123456789" },
        "name": "Quarterly report analysis",
        "status": "active",
        "kind": "user",
        "cost": { "credits": 15, "tokens": 1500 },
        "privacy": {
          "visibility": "private",
          "owner": { "type": "user", "id": "1234567890123456789" }
        },
        "created_at": "2026-07-07 16:00:00 UTC",
        "updated_at": "2026-07-07 16:30:05 UTC",
        "message_count": 5,
        "continuable": true,
        "latest_message": {
          "turn_id": "xYzAbCdEfGhIjKlMnO",
          "thread_id": "aBcDeFgHiJkLmNoPqR",
          "seq": 5,
          "status": "complete",
          "idempotency_key": "3f2a…",
          "query": { "text": "Summarize the quarterly report" },
          "error": null,
          "cost": { "credits": 3, "tokens": 300 },
          "created_at": "2026-07-07 16:30:00 UTC",
          "updated_at": "2026-07-07 16:30:05 UTC"
        }
      }
    ]
  }
}
FieldTypeDescription
chatsobjectCollection envelope {count, items}
chats.countintegerNumber of chat items returned in items
chats.itemsarrayArray of thread (chat) objects
chats.items[].thread_idstringOpaque ID of the thread (the chat)
chats.items[].creatorobject{type, id} — the chat creator
chats.items[].scopeobject{type, id} — the workspace or share the chat lives in
chats.items[].namestringChat display name
chats.items[].statusstringChat status
chats.items[].kindstringuser or agent. Always present; chats created before the field existed default to user.
chats.items[].message_countintegerTotal turns (messages) in the chat
chats.items[].continuablebooleantrue if the chat can be continued with a new message; false if the chat has no resumable conversation state (read-only history, e.g. a legacy chat migrated for history only). Omitted on create/update responses.
chats.items[].latest_messageobject/nullMost recent turn (see Message Object Schema), or null for an empty thread
chats.items[].cost.creditsintegerCredit charge for the chat (raw tokens converted at the meter rate)
chats.items[].cost.tokensintegerRaw token consumption the credit charge derives from
chats.items[].privacyobject{visibility, owner}
chats.items[].created_atstringCreation timestamp (YYYY-MM-DD HH:MM:SS UTC)
chats.items[].updated_atstringLast update timestamp (YYYY-MM-DD HH:MM:SS UTC)

Get chat details

GET /current/workspace/{workspace_id}/ai/agent/{chat_id}/details/

Returns chat details with full message history.

Auth: Bearer token required. content_ai plan feature required.

Request example:

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/details/" \
  -H "Authorization: Bearer {jwt_token}"

Response (200 OK):

The response is a thread object plus a separate turns collection (the message history) — there is no chat object and no embedded messages array.

{
  "result": true,
  "thread": {
    "thread_id": "aBcDeFgHiJkLmNoPqR",
    "creator": { "type": "user", "id": "1234567890123456789" },
    "scope": { "type": "workspace", "id": "1234567890123456789" },
    "name": "Quarterly report analysis",
    "status": "active",
    "kind": "user",
    "cost": { "credits": 15, "tokens": 1500 },
    "privacy": { "visibility": "private", "owner": { "type": "user", "id": "1234567890123456789" } },
    "created_at": "2026-07-07 16:00:00 UTC",
    "updated_at": "2026-07-07 16:30:05 UTC",
    "message_count": 3,
    "continuable": true
  },
  "turns": {
    "count": 1,
    "items": [
      {
        "turn_id": "xYzAbCdEfGhIjKlMnO",
        "thread_id": "aBcDeFgHiJkLmNoPqR",
        "seq": 1,
        "status": "complete",
        "idempotency_key": "3f2a…",
        "query": { "text": "Summarize the quarterly report" },
        "error": null,
        "cost": { "credits": 5, "tokens": 500 },
        "created_at": "2026-07-07 16:30:00 UTC",
        "updated_at": "2026-07-07 16:30:05 UTC"
      }
    ]
  }
}

The turns.items entries are the lightweight turn shape (no answer blob). Fetch a single turn’s full answer, citations, and action replay via Get message details below. turns is seq-ascending (oldest first).

Error responses:

Error CodeHTTP StatusCause
1609 (Not Found)404Chat not found or not accessible
1680 (Access Denied)401You do not have permission to access this thread

Update a chat

POST /current/workspace/{workspace_id}/ai/agent/{chat_id}/update/

Update the name of an existing chat. The chat kind is set at creation and cannot be changed via this endpoint — any kind value supplied in the body is silently ignored.

Auth: Bearer token required. content_ai plan feature required.

ParameterTypeRequiredDescription
namestringYesNew chat name

Request example:

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/update/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d "name=Updated Chat Name"

Response (200 OK):

{ "result": true }

Error responses:

Error CodeHTTP StatusCause
1658 (Not Acceptable)406Chat not found or locked
1605 (Invalid Input)406Invalid name value
1664 (Datastore Error)500Update failed

Delete a chat

DELETE /current/workspace/{workspace_id}/ai/agent/{chat_id}/

Auth: Bearer token required. content_ai plan feature required.

Request example:

curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/" \
  -H "Authorization: Bearer {jwt_token}"

Response (200 OK):

{ "result": true }

Error responses:

Error CodeHTTP StatusCause
1658 (Not Acceptable)406Chat not found or locked
1654 (Internal Error)500Chat in non-deletable state or internal error

Deleted chats can be listed via GET .../ai/agent/list/deleted.

Send a follow-up message

POST /current/workspace/{workspace_id}/ai/agent/{chat_id}/message/

Send a new message to an existing chat. The message is processed asynchronously.

Auth: Bearer token required. content_ai and ai_agent plan features required.

ParameterTypeRequiredDefaultDescription
questionstringYesFollow-up question, 1–32,000 characters. (May be omitted only when content_parts carries the message text.)
referencesJSON arrayNoFile/folder reference items to attach as context — each a {type, id} file or folder item (see Attaching Files and Folders). Up to 20 files / 2 GB / 100 references; the backend resolves each item’s full details server-side.
content_partsJSON arrayNoOrdered content stream — text segments plus inline file/folder reference pills (same item shape as references).
subjectsJSON arrayNoFile/folder reference items pinned as focus subjects for the turn (same item shape as references).
uploadsJSON arrayNoFocus files staged from outside the workspace/share.

Also accepts the optional view, activity, role_in_org, and idempotency_key fields (see Advanced Per-Turn Fields above). There is no type parameter — the turn is appended to the existing thread.

Request example:

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/message/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d "question=How does that compare to Q2?"

Response (200 OK):

{
  "result": true,
  "turn_id": "mNoPqRsTuVwXyZaBcD",
  "thread_id": "aBcDeFgHiJkLmNoPqR",
  "seq": 2,
  "status": "pending",
  "idempotency_key": "7c1b…",
  "query": { "text": "How does that compare to Q2?" },
  "error": null,
  "cost": { "credits": 0, "tokens": 0 },
  "created_at": "2026-07-07 16:37:29 UTC",
  "updated_at": "2026-07-07 16:37:29 UTC"
}

The created turn’s fields are returned at the top level (not nested under a message object). The turn_id is the message id you poll or stream. status starts at pending; watch it reach a terminal state (see “Get message details”).

Error responses:

Error CodeHTTP StatusCause
1658 (Not Acceptable)406Thread not found, not accessible, or locked
1680 (Access Denied)401You cannot message this thread; or (share chats only) folder attachment is not permitted for a restricted-view guest
1609 (Not Found)404An attached file or folder reference does not exist or is not accessible
1605 (Invalid Input)406An attached reference is malformed, the wrong node type, or exceeds the 20-file / 2 GB / 100-reference limit
1660 (Conflict)409The conversation has grown too large to continue — start a new chat
1664 (Datastore Error)500Transient storage error loading an attached file (retryable)
1654 (Internal Error)500Message creation or queuing failed

Cancel an in-progress message

POST /current/workspace/{workspace_id}/ai/agent/{chat_id}/cancel/

Aborts an in-flight AI message instead of waiting for it to finish or time out. The worker stops streaming and the turn transitions to a cancelled terminal state, after which a new message can be sent immediately.

Auth: Bearer token required. Workspace view permission. content_ai plan feature required (the cancel endpoint does not require ai_agent, so a tier downgrade mid-stream does not strand the message).

Body: Empty.

Request example:

curl -X POST "https://api.fast.io/current/workspace/{workspace_id}/ai/agent/{chat_id}/cancel/" \
  -H "Authorization: Bearer {jwt_token}"

Response (200 OK):

{ "result": true }

The response is always { "result": true } — there is no success, message.id, or no_pending_message field. It is the same whether a pending turn was signalled or there was nothing in flight (idempotent no-op).

Behavior notes:

Error responses:

Error CodeHTTP StatusCause
1658 (Not Acceptable)406Chat could not be loaded or the cancel signal could not be issued

List messages in a chat

GET /current/workspace/{workspace_id}/ai/agent/{chat_id}/messages/list/

Returns all messages in chronological order (oldest first).

Auth: Bearer token required. content_ai plan feature required.

Request example:

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/messages/list/" \
  -H "Authorization: Bearer {jwt_token}"

Response (200 OK):

{
  "result": true,
  "messages": {
    "count": 1,
    "items": [
      {
        "turn_id": "xYzAbCdEfGhIjKlMnO",
        "thread_id": "aBcDeFgHiJkLmNoPqR",
        "seq": 1,
        "status": "complete",
        "idempotency_key": "3f2a…",
        "query": { "text": "Summarize the quarterly report" },
        "error": null,
        "cost": { "credits": 5, "tokens": 500 },
        "created_at": "2026-07-07 16:30:00 UTC",
        "updated_at": "2026-07-07 16:30:05 UTC"
      }
    ]
  }
}

Each item is the lightweight turn shape (no answer blob) — see Message Object Schema. Fetch a turn’s full answer and citations via Get message details.

FieldTypeDescription
messagesobjectCollection envelope {count, items}
messages.countintegerNumber of turn items returned in items
messages.itemsarrayArray of turn objects, ordered oldest-first (seq ascending)

A numeric path segment after /messages/list/ is a pagination offset (e.g. .../messages/list/50).

Get message details

GET /current/workspace/{workspace_id}/ai/agent/{chat_id}/message/{message_id}/details/

Retrieve detailed information about a specific message, including response text, citations, and cost.

Auth: Bearer token required. content_ai plan feature required.

Request example:

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/message/xYzAbCdEfGhIjKlMnO/details/" \
  -H "Authorization: Bearer {jwt_token}"

The turn detail is returned under a message object on the workspace endpoint and a turn object on the share endpoint. It carries the full turn shape plus the decompressed answer result blob and the replayable actions list.

Key response fields (under message / turn):

FieldDescription
turn_idOpaque ID of this turn (message)
thread_idParent thread (chat) opaque ID
seqTurn sequence number within the thread
statusTurn status. One of pending, running, complete, failed, cancelled, lost, needs_input. (pending/running are non-terminal; the rest are terminal.)
idempotency_keyThe per-turn idempotency key
queryThe user’s submitted question: { text, content_parts?, references?, uploads?, subjects? } (internal server-only storage identifiers are stripped)
error{ message, grpc_status } on a failed/lost turn; null otherwise
cost{ credits, tokens } — the turn’s credit charge and raw token count
created_at / updated_atTimestamps (YYYY-MM-DD HH:MM:SS UTC)
resultThe decompressed answer blob (see below). null until the turn reaches a terminal state
actionsOrdered, replayable action cards (see below). Empty when the turn took no actions

Read the answer only when status is complete (or handle needs_input as a clarifying question — see the SSE needs_input event).

The result blob (when present) includes the answer text, references, citations (see Citation Format below), a truncated boolean (see below), the thought_transcript / commentary_transcript strings and their ordered thought_events / commentary_events lists (each event { order, text, ts }), and — for a needs_input turn — a clarification object { type: "clarification", question }.

result.truncated is true when the model reached its output-token limit and the answer is the partial response generated before the cutoff; it is false on every ordinary complete turn. Surface it (e.g. a “response was cut off” affordance) so users know the answer is incomplete.

The actions list is ordered by seq — each entry has seq (order), label (human-readable name, e.g. "Create File"), state (running, done, failed, or cancelled), affected_refs (ids the action touched), and started_at / ended_at timestamps:

{
  "message": {
    "turn_id": "xYzAbCdEfGhIjKlMnO",
    "thread_id": "aBcDeFgHiJkLmNoPqR",
    "seq": 1,
    "status": "complete",
    "query": { "text": "Summarize the quarterly report" },
    "error": null,
    "cost": { "credits": 5, "tokens": 500 },
    "result": {
      "answer": "The quarterly report shows revenue growth of 15%...",
      "citations": [
        {
          "hash": "a1b2c3d4",
          "nodeId": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
          "versionId": "3l5np-obens-xscsb",
          "entries": [
            { "page": 3, "snippet": "Revenue increased by 15% year over year...", "timestamp": null }
          ]
        }
      ],
      "truncated": false,
      "thought_transcript": "",
      "commentary_transcript": "",
      "thought_events": [],
      "commentary_events": []
    },
    "actions": [
      {
        "seq": 1,
        "label": "Create File",
        "state": "done",
        "affected_refs": ["aBcDeFgHiJkLmNoPqR"],
        "started_at": "2026-07-07 16:37:29 UTC",
        "ended_at": "2026-07-07 16:37:30 UTC"
      }
    ],
    "created_at": "2026-07-07 16:37:00 UTC",
    "updated_at": "2026-07-07 16:37:30 UTC"
  }
}

Error responses:

Error CodeHTTP StatusCause
1609 (Not Found)404Chat not found or not accessible
1683 (Resource Missing)404Message (turn) not found in the chat
1680 (Access Denied)401You do not have permission to access this thread
1654 (Internal Error)500Genuine internal/datastore failure

Stream message response (SSE)

GET /current/workspace/{workspace_id}/ai/agent/{chat_id}/message/{message_id}/read/

Returns a Server-Sent Events (SSE) stream of the AI response.

Auth: Bearer token required. content_ai plan feature required.

Request example:

curl -N -X GET "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/message/xYzAbCdEfGhIjKlMnO/read/" \
  -H "Authorization: Bearer {jwt_token}" \
  -H "Accept: text/event-stream"

SSE stream format:

event: data
data: {"item": "The quarterly report "}

event: data
data: {"item": "shows revenue growth of "}

event: data
data: {"item": "15% year over year."}

event: analysis_data
data: {"type": "analysis_chunk", ...}

event: table_data
data: {"type": "table_data", ...}

event: done

SSE event types:

Event TypeDescription
dataText chunks of the AI response. Payload: {"item": "..."}. Concatenate all data events for the full text.
eventStatus update or event notification
analysis_dataStructured analysis data, citations, and references to source files
commentaryInterim narration the AI emits while working. Payload: {"text": "...", "parts": [...]} — text is the flattened narration, parts the ordered content parts (text and inline object references). Expect one per work step on multi-step responses.
statusCosmetic turn-progress hint emitted before the agent's first output frame. Payload: {"phase": "...", "text": "..."} — phase is enhancing (first turn only, while the question is enhanced/evaluated; clients commonly show "Analyzing your request…") or invoking_agent (every turn, just before the agent runs; clients commonly show "Connecting agent…"), text a human-readable default you may show or override. Not persisted and not replayed from the durable record — treat as a best-effort indicator and clear it once real output (or a terminal event) arrives. Unknown phases → generic "working".
table_dataTabular data extracted or generated by the AI
needs_inputTerminal event: the assistant needs more information and returned a single clarifying question instead of a full response. The question text arrives on a preceding data frame (payload includes a question field); fetch the message details to read it from the result's clarification object. The message reaches a needs_input terminal state (not failed) — present the question and send the user's answer as a new message in the same chat. Listen for it as its own event; the stream closes after it.
doneStream complete. No more events will be sent.

Behavior:

Error responses:

Error CodeHTTP StatusCause
1683 (Resource Missing)404Message (turn) not found in the chat
1609 (Not Found)404Chat not found or not accessible
1680 (Access Denied)401You do not have permission to read this thread
1654 (Internal Error)500Genuine internal/datastore failure

Publish a private chat

POST /current/workspace/{workspace_id}/ai/agent/{chat_id}/publish/

Makes a private chat public (visible to other workspace members). One-way operation — published chats cannot be made private again.

Currently disabled (platform-wide). Publishing a chat publicly is turned off for all accounts: this endpoint returns 403 Forbidden with message "Publishing chats publicly is currently disabled.", and creating a chat with privacy=public is refused the same way. Clients can detect availability via the capabilities.can_publish_agent_chat boolean on workspace details (currently false) and hide the publish control. Chats already published before this change remain public.

Auth: Bearer token required. content_ai plan feature required.

Response (200 OK):

{ "result": true }

Error responses:

Error CodeHTTP StatusCause
1658 (Not Acceptable)406Chat not found or locked
1660 (Conflict)409Chat is already public
1664 (Datastore Error)500Update failed

Generate AI Share

POST /current/workspace/{workspace_id}/ai/share/

Generates markdown with temporary download URLs for selected files. Designed to be pasted into external AI chatbots.

Auth: Bearer token required. Workspace view permission. Does NOT require content_ai plan feature — available on all plans.

ParameterTypeRequiredDescription
filesarray (JSON)YesJSON array of file opaque IDs. Min 1, max 25.

Request example:

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/ai/share/" \
  -H "Authorization: Bearer {jwt_token}" \
  --data-urlencode 'files=["aBcDeFgHiJkLmN", "oPqRsTuVwXyZ12"]'

The endpoint reads form-encoded input (application/x-www-form-urlencoded). The files field value must be a JSON-encoded array of node opaque IDs. Do NOT send a JSON request body (Content-Type: application/json) — only form-encoded bodies are parsed.

Response (200 OK):

{
  "result": true,
  "markdown": "## Files\n\n### quarterly-report.pdf\n[Download](https://api.fast.io/...)\nSize: 2.5 MB\n\n..."
}
FieldTypeDescription
response.markdownstringGenerated markdown with file info and temporary download URLs

Notes:

Error responses:

Error CodeHTTP StatusCause
1605 (Invalid Input)406Empty files array
1605 (Invalid Input)406More than 25 files

List AI transactions

GET /current/workspace/{workspace_id}/ai/transactions/

Returns up to 40 most recent AI token usage transactions for the workspace. Workspace-only — no share equivalent.

Results merge two sources into one most-recent-first feed: standalone AI operations (file summaries, title generation, indexing, and other one-off AI tasks) and completed agent conversation turns. Agent-turn entries carry type agent; standalone operations carry their operation type (e.g. chat_with_files, generate_title). In-progress turns are not included — only finished work appears.

Auth: Bearer token required. Workspace view permission. content_ai plan feature required.

Request example:

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/ai/transactions/" \
  -H "Authorization: Bearer {jwt_token}"

Response (200 OK):

{
  "result": true,
  "count": 2,
  "items": [
    {
      "id": "txn_abc123",
      "type": "chat_with_files",
      "status": "complete",
      "tokens": 1500,
      "updated": "2025-06-15 10:30:05 UTC",
      "created": "2025-06-15 10:30:00 UTC"
    }
  ]
}
FieldTypeDescription
response.countintegerNumber of transactions returned
response.items[].idstringFormatted transaction or turn ID
response.items[].typestringOperation type (e.g., chat_with_files, generate_title) or agent for a completed agent conversation turn
response.items[].statusstringTransaction or turn status (e.g., complete, failed, cancelled, needs_input). needs_input marks a turn the assistant answered with a clarifying question instead of a full response.
response.items[].tokensintegerToken credits consumed
response.items[].updatedstringLast update timestamp (YYYY-MM-DD HH:MM:SS UTC)
response.items[].createdstringCreation timestamp (YYYY-MM-DD HH:MM:SS UTC)

Asking a Question and Getting the Response

Complete workflow

1. Create the chat:

curl -X POST "https://api.fast.io/current/workspace/{workspace_id}/ai/agent/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d "question=What were the Q3 revenue figures?"

The response includes thread.thread_id (the chat id) and turn.turn_id (the first message id). The AI begins processing asynchronously.

2. Wait for completion using activity polling (do NOT poll the message endpoint in a loop):

curl -X GET "https://api.fast.io/current/activity/poll/{workspace_id}?wait=95&lastactivity={timestamp}" \
  -H "Authorization: Bearer {jwt_token}"

Watch for the ai_chat:{chatId} activity key. This fires when the message state changes. The server holds the connection for up to 95 seconds and returns immediately when something changes.

3. Check message state:

curl -X GET "https://api.fast.io/current/workspace/{workspace_id}/ai/agent/{chat_id}/message/{message_id}/details/" \
  -H "Authorization: Bearer {jwt_token}"

Turn states: pendingrunningcomplete (or the terminal failed, cancelled, lost, or needs_input). Only read the answer when the status is complete (handle needs_input as a clarifying question).

4. Stream the response:

curl -N -X GET "https://api.fast.io/current/workspace/{workspace_id}/ai/agent/{chat_id}/message/{message_id}/read/" \
  -H "Authorization: Bearer {jwt_token}" \
  -H "Accept: text/event-stream"

Returns SSE with event types: data (text chunks), commentary (interim narration), status (turn-progress hints, before the agent’s first frame), analysis_data, table_data, needs_input (terminal: a single clarifying question instead of a full answer — present it and send the user’s reply as a new message), done.

5. Send follow-ups:

curl -X POST "https://api.fast.io/current/workspace/{workspace_id}/ai/agent/{chat_id}/message/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d "question=How does that compare to Q2?"

Same polling flow for the reply.

Linking a user to an AI chat

Construct a workspace URL with a chat query parameter:

https://{org_domain}.fast.io/workspace/{workspace_name}?chat={chat_opaque_id}

The chat_opaque_id is the thread’s opaque id — returned as thread.thread_id when creating a chat, and as each item’s thread_id when listing chats.

Share AI Endpoints

Share AI endpoints follow the same pattern as workspace AI. Replace /workspace/{workspace_id} with /share/{share_id}.

Auto-generate OG image

GET /current/share/{share_id}/ai/autoog/

Generates an Open Graph image for the share. Returns binary PNG image data (not JSON).

Auth: Conditional. Public shares (Anyone / RegisteredUsers) need no authentication and get a custom AI-generated image. Private shares require Bearer auth and the ai_autoog plan feature; without valid permission or the feature, the endpoint falls back to the default private OG image (still HTTP 200 image data).

BehaviorDescription
Public shareCustom AI-generated image based on share content
Private shareCustom image when the caller has permission and the ai_autoog feature; otherwise the default private image

Error responses:

Error CodeHTTP StatusCause
1609 (Not Found)404Share is disabled
1654 (Internal Error)500Default image not found on server

Auto-generate title and description

POST /current/share/{share_id}/ai/autotitle/

AI-generates a title, description, and display type based on the share’s contents. Values are applied directly to the share.

Auth: Bearer token required. Requires the ai_autotitle plan feature (not content_ai/ai_agent) — a plan without it is rejected.

ParameterTypeRequiredDescription
user_contextstringNoOptional user-provided context to guide AI generation

Request example:

curl -X POST "https://api.fast.io/current/share/1234567890123456789/ai/autotitle/" \
  -H "Authorization: Bearer {jwt_token}"

Response (200 OK):

{
  "result": true,
  "title": "Q4 Financial Reports",
  "description": "Quarterly financial reports and analysis for fiscal year 2025.",
  "display_type": "document"
}
FieldTypeDescription
response.titlestringAI-generated title
response.descriptionstringAI-generated description
response.display_typestringAI-suggested display type

Error responses:

Error CodeHTTP StatusCause
1680 (Access Denied)401Insufficient share permissions
1654 (Internal Error)500Share update or generation failure

Share chat endpoints mirror workspace chat endpoints. All response formats and schemas are identical to the workspace versions documented above. The key differences are:

Create a new chat (Share)

POST /current/share/{share_id}/ai/agent/

Creates a chat with an initial message in a share. The AI begins processing asynchronously.

Auth: Bearer token required. Share view permission and chat permission. content_ai and ai_agent plan features required.

ParameterTypeRequiredDefaultDescription
questionstringYesInitial question, 1–32,000 characters. (May be omitted only when content_parts carries the message text.)
namestringNoAuto-generatedChat name. A default is used if omitted.
kindstringNouserShare-context creation only produces user chats.
referencesJSON arrayNoFile/folder reference items to attach as context — each a {type, id} file or folder item (see Attaching Files and Folders). Up to 20 files / 2 GB / 100 references; the backend resolves each item’s full details server-side.
content_partsJSON arrayNoOrdered content stream — text segments plus inline file/folder reference pills (same item shape as references).
subjectsJSON arrayNoFile/folder reference items pinned as focus subjects for the turn (same item shape as references).
uploadsJSON arrayNoFocus files staged from outside the share.

Share-context chats are always private — the privacy parameter is not accepted and visibility is fixed so guests do not see each other's AI conversations. Also accepts the optional view, activity, role_in_org, and idempotency_key fields.

Request example:

curl -X POST "https://api.fast.io/current/share/1234567890123456789/ai/agent/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d "question=What were the Q3 revenue figures?"

Response (200 OK):

Same { thread, turn } shape as the workspace create endpoint. thread.thread_id is the chat id; turn.turn_id is the first message id. See Create a new chat (workspace) above for the full field lists.

{
  "result": true,
  "thread": { "thread_id": "aBcDeFgHiJkLmNoPqR", "scope": { "type": "share", "id": "1234567890123456789" }, "status": "active", "kind": "user", "...": "..." },
  "turn": { "turn_id": "xYzAbCdEfGhIjKlMnO", "thread_id": "aBcDeFgHiJkLmNoPqR", "seq": 1, "status": "pending", "...": "..." }
}

Error responses:

Error CodeHTTP StatusCause
1605 (Invalid Input)406Invalid kind or name, or invalid question length
1609 (Not Found)404An attached file or folder reference does not exist or is not accessible
1605 (Invalid Input)406An attached reference is malformed, the wrong node type, or exceeds the 20-file / 2 GB / 100-reference limit
1680 (Access Denied)401Folder attachment is not permitted in this share (restricted-view guest)
1660 (Conflict)409Thread still committing its first turn (retry), or the first message is too large
1664 (Datastore Error)500Thread or turn creation failed

List chats (Share)

GET /current/share/{share_id}/ai/agent/list/

Returns all chats created by the current user in the share. Sorted by most recently modified first.

Auth: Bearer token required. Share view permission and chat permission. content_ai plan feature required.

Query parameters:

ParameterTypeRequiredDefaultDescription
kindstringNouserFilter by chat kind. Allowed values: user (default), agent, all. Same semantics as the workspace list endpoint. Note: share-context chat creation does not accept kind, so all share-created chats are user.

Variant: Append /deleted to the path to list deleted chats: GET .../ai/agent/list/deleted

Request example:

curl -X GET "https://api.fast.io/current/share/1234567890123456789/ai/agent/list/" \
  -H "Authorization: Bearer {jwt_token}"

Response: Same format as workspace chat list. See List chats above.

Get chat details (Share)

GET /current/share/{share_id}/ai/agent/{chat_id}/details/

Returns chat details with full message history.

Auth: Bearer token required. Share view permission and chat permission. content_ai plan feature required.

Request example:

curl -X GET "https://api.fast.io/current/share/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/details/" \
  -H "Authorization: Bearer {jwt_token}"

Response: Same format as workspace chat details. See Get chat details above.

Error responses:

Error CodeHTTP StatusCause
1609 (Not Found)404Chat not found or not accessible
1680 (Access Denied)401You do not have permission to access this thread

Update a chat (Share)

POST /current/share/{share_id}/ai/agent/{chat_id}/update/

Update the name of an existing chat in a share.

Auth: Bearer token required. Share view permission and chat permission. content_ai plan feature required.

ParameterTypeRequiredDescription
namestringYesNew chat name

Request example:

curl -X POST "https://api.fast.io/current/share/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/update/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d "name=Updated Chat Name"

Response (200 OK):

{ "result": true }

Error responses:

Error CodeHTTP StatusCause
1658 (Not Acceptable)406Chat not found or locked
1605 (Invalid Input)406Invalid name value
1664 (Datastore Error)500Update failed

Delete a chat (Share)

DELETE /current/share/{share_id}/ai/agent/{chat_id}/

Auth: Bearer token required. Share view permission and chat permission. content_ai plan feature required.

Request example:

curl -X DELETE "https://api.fast.io/current/share/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/" \
  -H "Authorization: Bearer {jwt_token}"

Response (200 OK):

{ "result": true }

Error responses:

Error CodeHTTP StatusCause
1658 (Not Acceptable)406Chat not found or locked
1654 (Internal Error)500Chat in non-deletable state or internal error

Deleted chats can be listed via GET .../ai/agent/list/deleted.

Send a follow-up message (Share)

POST /current/share/{share_id}/ai/agent/{chat_id}/message/

Send a new message to an existing chat in a share. The message is processed asynchronously.

Auth: Bearer token required. Share view permission and chat permission. content_ai and ai_agent plan features required.

ParameterTypeRequiredDefaultDescription
questionstringYesFollow-up question, 1–32,000 characters. (May be omitted only when content_parts carries the message text.)
referencesJSON arrayNoFile/folder reference items to attach as context — each a {type, id} file or folder item (see Attaching Files and Folders). Up to 20 files / 2 GB / 100 references; the backend resolves each item’s full details server-side.
content_partsJSON arrayNoOrdered content stream — text segments plus inline file/folder reference pills (same item shape as references).
subjectsJSON arrayNoFile/folder reference items pinned as focus subjects for the turn (same item shape as references).
uploadsJSON arrayNoFocus files staged from outside the workspace/share.

Also accepts the optional view, activity, role_in_org, and idempotency_key fields (see Advanced Per-Turn Fields above). There is no type parameter — the turn is appended to the existing thread.

Request example:

curl -X POST "https://api.fast.io/current/share/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/message/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d "question=How does that compare to Q2?"

Response (200 OK):

{
  "result": true,
  "turn_id": "mNoPqRsTuVwXyZaBcD",
  "thread_id": "aBcDeFgHiJkLmNoPqR",
  "seq": 2,
  "status": "pending",
  "idempotency_key": "7c1b…",
  "query": { "text": "How does that compare to Q2?" },
  "error": null,
  "cost": { "credits": 0, "tokens": 0 },
  "created_at": "2026-07-07 16:37:29 UTC",
  "updated_at": "2026-07-07 16:37:29 UTC"
}

The created turn’s fields are returned at the top level (not nested under a message object). The turn_id is the message id you poll or stream. status starts at pending; watch it reach a terminal state (see “Get message details”).

Error responses:

Error CodeHTTP StatusCause
1658 (Not Acceptable)406Thread not found, not accessible, or locked
1680 (Access Denied)401You cannot message this thread; or (share chats only) folder attachment is not permitted for a restricted-view guest
1609 (Not Found)404An attached file or folder reference does not exist or is not accessible
1605 (Invalid Input)406An attached reference is malformed, the wrong node type, or exceeds the 20-file / 2 GB / 100-reference limit
1660 (Conflict)409The conversation has grown too large to continue — start a new chat
1664 (Datastore Error)500Transient storage error loading an attached file (retryable)
1654 (Internal Error)500Message creation or queuing failed

Cancel an in-progress message (Share)

POST /current/share/{share_id}/ai/agent/{chat_id}/cancel/

Aborts an in-flight AI message in a share chat. Behavior matches the workspace cancel endpoint above: the worker halts streaming and the affected turn reaches a cancelled terminal state, after which a new message can be sent.

Auth: Bearer token required. Share view permission and chat permission. content_ai plan feature required (the cancel endpoint does not require ai_agent).

Body: Empty.

Request example:

curl -X POST "https://api.fast.io/current/share/{share_id}/ai/agent/{chat_id}/cancel/" \
  -H "Authorization: Bearer {jwt_token}"

Response: { "result": true } — same as the workspace cancel endpoint (no success, message.id, or no_pending_message field). See Cancel an in-progress message above for the full behavior notes (idempotency, best-effort latency, partial billing, SSE cancelled event, and the affected turn reaching the cancelled terminal state).

Error responses:

Error CodeHTTP StatusCause
1658 (Not Acceptable)406Chat could not be loaded or the cancel signal could not be issued

List messages in a chat (Share)

GET /current/share/{share_id}/ai/agent/{chat_id}/messages/list/

Returns all messages in chronological order (oldest first).

Auth: Bearer token required. Share view permission and chat permission. content_ai plan feature required.

Request example:

curl -X GET "https://api.fast.io/current/share/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/messages/list/" \
  -H "Authorization: Bearer {jwt_token}"

Response: Same format as workspace message list. See List messages in a chat above.

Get message details (Share)

GET /current/share/{share_id}/ai/agent/{chat_id}/message/{message_id}/details/

Retrieve detailed information about a specific message, including response text, citations, and cost.

Auth: Bearer token required. Share view permission and chat permission. content_ai plan feature required.

Request example:

curl -X GET "https://api.fast.io/current/share/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/message/xYzAbCdEfGhIjKlMnO/details/" \
  -H "Authorization: Bearer {jwt_token}"

Response: Same format as workspace message details. See Get message details above.

Error responses:

Error CodeHTTP StatusCause
1609 (Not Found)404Chat not found or not accessible
1683 (Resource Missing)404Message (turn) not found in the chat
1680 (Access Denied)401You do not have permission to access this thread
1654 (Internal Error)500Genuine internal/datastore failure

Stream message response (SSE) (Share)

GET /current/share/{share_id}/ai/agent/{chat_id}/message/{message_id}/read/

Returns a Server-Sent Events (SSE) stream of the AI response.

Auth: Bearer token required. Share view permission and chat permission. content_ai plan feature required.

Request example:

curl -N -X GET "https://api.fast.io/current/share/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/message/xYzAbCdEfGhIjKlMnO/read/" \
  -H "Authorization: Bearer {jwt_token}" \
  -H "Accept: text/event-stream"

SSE stream format and behavior: Identical to the workspace version. See Stream message response (SSE) above.

Error responses:

Error CodeHTTP StatusCause
1683 (Resource Missing)404Message (turn) not found in the chat
1609 (Not Found)404Chat not found or not accessible
1680 (Access Denied)401You do not have permission to read this thread
1654 (Internal Error)500Genuine internal/datastore failure

Publish a private chat (Share)

POST /current/share/{share_id}/ai/agent/{chat_id}/publish/

Makes a private chat public (visible to other share members). One-way operation — published chats cannot be made private again.

Currently disabled (platform-wide). Publishing a chat publicly is turned off for all accounts: this endpoint returns 403 Forbidden with message "Publishing chats publicly is currently disabled." Clients can detect availability via the capabilities.can_publish_agent_chat boolean on share details (currently false) and hide the publish control. Chats already published before this change remain public.

Auth: Bearer token required. Share view permission and chat permission. content_ai plan feature required.

Request example:

curl -X POST "https://api.fast.io/current/share/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/publish/" \
  -H "Authorization: Bearer {jwt_token}"

Response (200 OK):

{ "result": true }

Error responses:

Error CodeHTTP StatusCause
1658 (Not Acceptable)406Chat not found or locked
1660 (Conflict)409Chat is already public
1664 (Datastore Error)500Update failed

Generate AI Share (Share)

POST /current/share/{share_id}/ai/share/

Generates markdown with temporary download URLs for selected files. Designed to be pasted into external AI chatbots.

Auth: Bearer token required. Share view permission and download permission. Does NOT require content_ai plan feature — available on all plans.

ParameterTypeRequiredDescription
filesarray (JSON)YesJSON array of file opaque IDs. Min 1, max 25.

Request example:

curl -X POST "https://api.fast.io/current/share/1234567890123456789/ai/share/" \
  -H "Authorization: Bearer {jwt_token}" \
  --data-urlencode 'files=["aBcDeFgHiJkLmN", "oPqRsTuVwXyZ12"]'

The endpoint reads form-encoded input (application/x-www-form-urlencoded). The files field value must be a JSON-encoded array of node opaque IDs. Do NOT send a JSON request body (Content-Type: application/json) — only form-encoded bodies are parsed.

Response (200 OK):

{
  "result": true,
  "markdown": "## Files\n\n### quarterly-report.pdf\n[Download](https://api.fast.io/...)\nSize: 2.5 MB\n\n..."
}
FieldTypeDescription
response.markdownstringGenerated markdown with file info and temporary download URLs

Notes:

Error responses:

Error CodeHTTP StatusCause
1605 (Invalid Input)406Empty files array
1605 (Invalid Input)406More than 25 files
1680 (Access Denied)401Insufficient download permissions

Workspace AI vs. Share AI differences

FeatureWorkspace AIShare AI
AI Transactions endpointYesNo
Auto OG image endpointNoYes
Auto title endpointNoYes
content_ai feature requiredYes (except AI Share)Yes (except AI Share)
ai_agent feature requiredYes, for create-chat and send-message (except AI Share)Yes, for create-chat and send-message (except AI Share)
File scope contextWorkspace filesShare files

AI Share File Download

GET /current/ai/share/{token}?file={index}

Download a file from an AI Share using a temporary token. No authentication required — access is controlled by the token.

ParameterTypeRequiredDescription
{token}string (path)YesAlphanumeric AI share token (generated by the AI Share creation endpoint)
fileinteger (query)YesZero-based file index within the AI Share

Request example:

curl -X GET "https://api.fast.io/current/ai/share/aBcDeFgHiJkLmNoPqRsTuVwXyZ?file=0" \
  -o downloaded_file.pdf

Success response: Binary file data with appropriate Content-Type, Content-Disposition, Content-Length, and Accept-Ranges headers. Supports HTTP range requests.

Error responses:

Error CodeHTTP StatusCause
1609 (Not Found)404Token missing, invalid, expired, or use limit reached
1609 (Not Found)404File index out of range
1654 (Internal Error)500Unable to retrieve or read file

All invalid/expired token errors return 404 to prevent token enumeration.

Semantic search runs inside the unified storage search endpoint — there is no separate semantic endpoint. When workspace intelligence is enabled, meaning-based matches are blended into the results automatically, alongside filename and summary matches.

GET /current/workspace/{workspace_id}/storage/search/

Auth: Bearer token required. Workspace view permission. The endpoint has no plan-feature gate of its own — only its meaning-based leg depends on the workspace having intelligence enabled.

GET /current/share/{share_id}/storage/search/

The same search, scoped to a share. It is not identical to the workspace route: it applies share-specific search and file-view permissions, gates summary access separately, rejects workspace-backed shares, and does not accept filters.

Parameters: the query goes in search. The full list — search, files_scope, folders_scope, search_in, name_match, case_sensitive, details, limit/offset, the output detail tiers, and the workspace-only filters — is documented in the Search section of the Storage reference. The notes below cover only the behaviour specific to the meaning-based leg.

Everything below applies ONLY when the meaning-based leg actually runs — that is, workspace intelligence is enabled and search_in is not filename. When the leg does not run, files_scope / folders_scope are not parsed at all: a malformed entry is silently ignored rather than refused, an oversized folder tree is neither expanded nor reported, and the scope has no effect on the results you get back.

A scope that resolves to nothing returns no MEANING-BASED results. If every reference in files_scope / folders_scope is dropped during resolution — for example a file that has since been trashed — the meaning-based leg returns nothing. It never falls back to searching everything. ⚠ The keyword and summary channels are not scoped, so the response can still carry files from outside the scope, marked match_source: "keyword". Omit both parameters to search all indexed content.

🔴 A scope is NOT a result boundary here. It narrows the meaning-based leg only; the keyword/summary leg is unscoped and its hits come back regardless. If you need a hard boundary — for display, for an access decision, or for anything a user will read as “only these files” — filter the results yourself on match_source. Treating the scope as a boundary will show out-of-scope files.

files_scope takes files AND notes; folders_scope takes folders; links cannot be scoped. Notes are indexed the 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. 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, in a message naming the type the node actually is and, where the other parameter would take it, which one to use instead.

To send no scope, omit the parameter. Any value that is not a nodeId:versionId / nodeId:depth pair is refused with 1605 (Invalid Input) / 406 naming the entry — including a bare 0, which is not treated as “no scope”.

A scope carries at most 100 references in total, counting every file named, every folder named, and 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 truncated instead, and the truncation is reported — the response then carries search_metadata.scope_incomplete: true, meaning the search covered less than you asked for. The key is absent when nothing was left out. Narrow the :depth, or name fewer folders, and retry.

A failed meaning-based leg degrades the request, it does not fail it. If the semantic lookup cannot be completed, the response is still 200 carrying the keyword results, and search_metadata.semantic_available reports false.

search_metadata is emitted only when you send search_in. A request that omits it takes the historical response shape and carries no search_metadata at all — so the degradation is invisible and a keyword-only answer is indistinguishable from a complete semantic one. Send search_in=both explicitly if you need to detect this, then read semantic_available before treating a short result set as complete.

Examples:

# Basic search
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=quarterly%20revenue&limit=10" \
  -H "Authorization: Bearer {jwt_token}"

# Search with full node details
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=quarterly%20revenue&details=true" \
  -H "Authorization: Bearer {jwt_token}"

Response (200 OK):

{
  "result": true,
  "response_code": 200,
  "files": {
    "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4": {
      "name": "quarterly-report.pdf",
      "parent_id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5",
      "type": "file",
      "relevance_score": 1.0,
      "raw_score": 0.87,
      "score_source": "semantic",
      "content_snippet": "The quarterly revenue showed a 15% increase...",
      "match_source": "both",
      "media_segment": null,
      "mimetype": "application/pdf",
      "page": { "start_page": 3, "end_page": 3 }
    }
  },
  "search_metadata": {
    "intelligence_enabled": true,
    "semantic_available": true,
    "scoped": false
  }
}

The response is a map of node id → file entry, not a results array. Use details=true to attach the full node resource to each entry. A pagination block (total, limit, offset, has_more) accompanies the map.

Hybrid response fields (intelligence enabled):

When workspace intelligence is enabled, each file entry in the /storage/search response includes additional semantic fields:

FieldTypeDescription
relevance_scorefloatA rank-fusion score over the two retrieval legs — the name/text search and the content search — combined by each file’s position in each leg and normalised within this result set, so the maximum score in the set is exactly 1.0 (not necessarily the first row — ordering is tier-first) and its scale is re-derived for every query. Use it to order results — do not threshold it, and do not compare it across queries.
raw_scorefloat/nullThe un-rescaled retrieval score, on the scale named by score_source. The merge does not divide, clamp or round it against the other hits that came back with it — a statement about rescaling, not a promise the number is constant, since a keyword score is BM25 and moves with the index statistics. Not bounded to 0.0–1.0. null when score_source is filename.
score_sourcestringIdentifies the scale raw_score is on — nothing more. One of keyword (a 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), or filename (the row was placed by a name match, not by a measured score, so raw_score is null). On a file both legs found, the leg reported is the one that ranked it higher, and on an equal position the keyword leg, whose score is always a real measurement. There is no both — which legs matched is answered by match_source. Decided per hit, so one response can carry all three: group by score_source before comparing any two raw_score values.
content_snippetstring/nullThe actual matching text from semantic search. NULL for keyword-only matches. Trimmed by the output query param — on /storage/search/ and on the unified find search (GET /workspace/{id}/search/ and its share twin) alike, to the same budget (terse ~200 bytes, standard ~600 bytes, full untrimmed; truncated values end with …).
match_sourcestringWhich legs of the hybrid search matched: keyword, semantic, or bothboth means BOTH legs matched, not that several semantic passages did
mimetypestringFile MIME type (e.g., application/pdf, audio/mpeg). Present for semantic matches.
media_segmentobjectOnly for audio/video matches when intelligence is on. Contains start_seconds and end_seconds for deep-linking to the exact timestamp range.
search_metadataobjectAdditional search metadata: intelligence_enabled, semantic_available, scoped, plus content_search_available and reason when search_in was supplied. See Search in the Storage reference.

raw_score and score_source are on /storage/search/ only — the unified /search/ route does not return them.

Controlling whether the semantic channel runs at all:

/storage/search/ takes an optional search_in=filename|content|both (default both). search_in=filename matches the file's name only and skips the semantic lookup entirely — useful when you know what a file is called and want a fast, predictable answer. search_in=content matches the AI's understanding of the file: its AI-generated summary plus the meaning-based index. It is not a text scan of the file's bytes — there is no full-text index of file contents.

Those are two channels, and intelligence gates only the meaning-based one. Switching AI features off stops new semantic matching but does not un-index summaries already written, so content still returns hits for files summarized earlier and returns nothing when there are none. Either channel needs the file to have reached ai.state: indexed at some point.

When neither content channel can serve a request, the response is 200 with an empty result set and search_metadata.content_search_available: false plus a reason (intelligence_disabled, summary_permission_denied, or content_not_indexed). Do not report that as "no files found" — retry with search_in=filename. Note that content_search_available reports whether content search can work here, not whether it will match: it is reachable as false only on a share where neither channel is open, and is always true on a workspace. For the outcome of a given request, read search_metadata.semantic_available. Two companion parameters shape filename matching: name_match=auto|exact|prefix|contains|glob and case_sensitive. Full details, including the glob syntax and the escaping rules, are in the Search section of the Storage reference.

Response with details=true (via /storage/search):

When using the preferred /storage/search endpoint with ?details=true, each file entry includes a node field with the full node resource:

{
  "result": true,
  "files": {
    "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4": {
      "name": "report.pdf",
      "parent_id": "...",
      "type": "file",
      "relevance_score": 1.0,
      "raw_score": 0.87,
      "score_source": "semantic",
      "content_snippet": "Revenue increased 15%...",
      "match_source": "both",
      "mimetype": "application/pdf",
      "node": {
        "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
        "name": "report.pdf",
        "type": "file",
        "size": 123456,
        "previews": { "...": "..." },
        "ai": { "state": "indexed" }
      }
    }
  }
}

Error responses:

Error CodeHTTP StatusCause
1605 (Invalid Input)406A bad files_scope / folders_scope entry — 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, a folder where a file was expected, or a file where a folder was expected. The message names the offending entry
1693 (Temporarily Unavailable)503A metadata filters predicate could not be evaluated — retry shortly. A failed semantic leg does not reach here; it degrades to 200 with search_metadata.semantic_available: false
1605 (Invalid Input)406folders_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

How to Phrase Questions

With folder/file scope (RAG)

Write questions that will match content in indexed files. The AI searches for relevant passages and cites them. Be specific.

With file attachments

You can be more direct since the AI has the full file content.

Chat Session Object Schema

The thread (chat) resource is returned as thread by the details endpoint and as each chats.items[] entry by the list endpoint. The details endpoint returns the message history separately as a turns collection — there is no embedded messages array on the thread.

FieldTypeDescription
thread_idstringOpaque ID of the thread (the chat)
creatorobject{type: string, id: string} — the chat creator
scopeobject{type: string, id: string} — the workspace or share the chat lives in
namestringDisplay name of the chat
statusstringCurrent chat status
kindstringuser or agent — set at creation, immutable thereafter
costobject{credits: int, tokens: int} — credit charge and the raw token count it derives from
privacyobject{visibility: "private"|"public", owner: {type, id}|null}
created_atstringCreation timestamp (YYYY-MM-DD HH:MM:SS UTC)
updated_atstringLast update timestamp (YYYY-MM-DD HH:MM:SS UTC)
message_countintegerTotal turns (read endpoints only; omitted on mutation acks)
continuablebooleanWhether the chat can be continued (read endpoints only)
latest_messageobject/nullMost recent turn preview (list endpoint only)

There is no type, unique_creators, or efficiency field.

Message Object Schema

A message is a turn. The lightweight shape (list/details/mutation responses) carries the fields above the divider; the per-turn detail endpoint additionally returns result and actions.

FieldTypeDescription
turn_idstringOpaque ID of the turn (the message)
thread_idstringParent thread (chat) opaque ID
seqintegerTurn sequence number within the thread
statusstringpending, running, complete, failed, cancelled, lost, or needs_input
idempotency_keystringPer-turn idempotency key
queryobjectThe user’s submitted question: { text, content_parts?, references?, uploads?, subjects? }
errorobject/null{ message: string, grpc_status: int|null } on a failed/lost turn; null otherwise
costobject{ credits: int, tokens: int } — the turn’s credit charge and raw token count
created_atstringCreation timestamp (YYYY-MM-DD HH:MM:SS UTC)
updated_atstringLast update timestamp (YYYY-MM-DD HH:MM:SS UTC)
resultobject/nullDetail view only. Decompressed answer blob: answer, references, citations, truncated (bool — true when the answer was cut off at the model's output-token limit), thought_transcript, commentary_transcript, thought_events, commentary_events, and (for needs_input) clarification. null until the turn is terminal
actionsarrayDetail view only. Ordered, replayable action cards (seq, label, state, affected_refs, started_at, ended_at)

There is no state, personality, response, author_name, or top-level text/citations/events field — the answer and its citations live inside the detail view’s result blob, and the processing state is status.

Citation Format

Citations appear inside a completed turn’s result blob (under result.citations / result.references), fetched from Get message details. They reference specific locations in files that informed the AI response.

FieldTypeDescription
hashstringFile content hash (used for grouping)
nodeIdstringStorage node opaque ID
versionIdstringFile version opaque ID
entriesarrayCitation locations within the file
entries[].pageintegerPage number in the document
entries[].snippetstring/nullRelevant text excerpt
entries[].timestampfloat/nullTimestamp for audio/video files (seconds)

Activity Polling for AI Chat Completion

Do NOT poll the message details endpoint in a loop. Use activity long-polling instead.

GET /current/activity/poll/{workspace_id}?wait=95&lastactivity={timestamp}

The server holds the connection for up to 95 seconds and returns immediately when something changes. Watch for the ai_chat:{chatId} activity key — this fires when the message state changes.

Activity Key PatternWhat Changed
ai_chat:{chatId}AI chat message state updated
storage:{fileId}File added, updated, or removed
preview:{fileId}File preview/thumbnail is ready

Pass the returned lastactivity timestamp into your next poll to receive only newer changes.

Anti-pattern: Do not GET .../ai/agent/{id}/message/{id}/details/ in a loop. Poll once on the workspace activity endpoint and wait for the ai_chat key.

Compact Responses (output=)

Every metadata endpoint that returns records accepts an optional output query parameter that selects the shape of each record in the response — object-metadata, node-facts, saved-filter, eligible-node and field-vocabulary records all take it. 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.

Object metadata (GET /workspace/{id}/storage/{node}/metadata/details/):

LevelFields returned on each metadata record (cumulative)
tersemetadata_facts (field NAMES only), object_id, template_id, node_id (narrowed to {id, name, type})
standardterse + instance_id, node_id widened to {id, name, type, parent, mimetype}, autoextractable; metadata_facts becomes field, an abbreviated value, and value_truncated
fulleverything

The fact payload is the point of a metadata response, so terse keeps it. The savings come from trimming the nested node pointer — terse carries only the minimum identity fields needed to address the node in a follow-up call, standard adds parent and mimetype for list rendering, and full keeps the entire node resource.

metadata_facts survives EVERY tier here for the same reason it does on eligible nodes, and in the same per-tier shapes tabulated below — it is the file’s metadata, so a tier that dropped it would leave nothing worth returning. Its position does not move either: it is the first key of the payload at every tier. See Get file metadata for the full contract.

Saved filters (GET /workspace/{id}/metadata/filters/):

LevelFields returned (cumulative)
terseid, name, description
standardterse + predicate, projection, template_id, created, updated
fulleverything

Eligible nodes (GET /workspace/{id}/metadata/eligible/):

LevelFields returned (cumulative)
tersemetadata_facts (field NAMES only), node_id, parent_id, name, mimetype
standardterse + size, summary_title, summary_short, updated, templates; metadata_facts becomes field, an abbreviated value, and value_truncated
fulleverything

metadata_facts survives EVERY tier, including terse — what changes per tier is its shape, not whether it is there. That is deliberate: is_truncated and total live inside the block, and a consumer that cannot see them cannot know there is more to ask for, or how much more.

It is also the first key of every eligible-node row, at every output level, for the same reason it leads the metadata payload: a client that flattens a row by taking the first collection it finds must land on the live corpus and not on the row’s templates id list.

parent_id is present at terse too, because grouping a large listing by folder is exactly the job the light tier exists for.

Levelmetadata_facts shape
terse{"count": 2, "total": 2, "is_truncated": false, "fields": "customer, invoice_total"} — names only, no value at any depth
standard{"count": 2, "total": 2, "is_truncated": false, "items": [{"field": "customer", "value": "Acme Corp", "value_truncated": false}]} — values abbreviated, provenance dropped, value_truncated always present
full{"count": 2, "total": 2, "is_truncated": false, "items": [<the complete fact record>]}total is the one key that does not move between tiers: it counts what the node HOLDS, so all three rows report the same number

Use ?output=terse when you want to know WHICH fields a workspace actually holds values for without paying for the values — a field census across a page costs one short string per row.

Field vocabulary (GET /workspace/{id}/metadata/fields/):

LevelFields returned (cumulative)
tersename, declared_type
standardterse + constraints, aliases, provisional, origin, file_count
fulleverything

Two reserved fields have a CLOSED value list. category and sub_category are assigned by extraction to every file it classifies, and are the only fields whose values are drawn from a fixed set:

The list is published as constraints.allowed on each of those two fields in the field-vocabulary response, computed from the same list the writer enforces — so build a picker from that rather than hardcoding it. It constrains what is written from now on; rows that predate it can still hold other values, so keep tolerating an unknown one.

Every other field is open vocabulary. These two are closed so a filter can be written without discovering the workspace first. Extraction never writes a value outside the list — an off-list answer is replaced with Other before it is stored, and the stored spelling is always the one above, so a filter on Finance needs no finance variant. Three sets of rows are outside that guarantee: values migrated from the template system, files extracted before the list was closed on 2026-08-23, and values inherited by a COPY — a copy reuses the original’s stored bytes and inherits its metadata rather than being re-extracted, so it can inherit a classification recorded before the list existed. Both were recorded before the list existed — the migrated set includes human-typed values AND the previous extractor's output — and both can hold spellings the list does not contain. Other is a correct answer for a document that fits nothing, not an error. Read ?field=category on the vocabulary route for the values a particular workspace actually holds, with occurrence counts.

A value that does not fit its declared type is DROPPED, not corrected. In an extraction run the field keeps its previous value, the rest of the run lands, and the job still succeeds — so a malformed extracted value is indistinguishable from a field the document never mentioned. Extraction emits these shapes and so should you:

Money is two fields. Give the amount as a plain number declared float — always float, even for a whole amount, because a field's type is fixed by its first value and an int field silently drops every later fractional one. Give the ISO 4217 code as a string in a field named after the amount with _currency appended — invoice_total = 1234.56, invoice_total_currency = USD. Two caveats: the pair is not atomic, so either half can be refused while the other lands; and a range over the amount alone still compares USD against EUR as one number, so predicate on the currency field too.

The same shapes bind YOUR writes, but a bad one FAILS DIFFERENTLY. Where extraction skips what it cannot fit and carries on, POST .../metadata/facts/ refuses the whole request: nothing is written, every field keeps what it had, and error.params names each offending field. So a malformed value in your own write is loud rather than silent, and the fix is to correct the entry and resend the whole payload. Verify a write by reading the values back anyway, since a value can be accepted and still stored in a shape you did not intend.

A datetime with no time of day is written date-only and stored as midnight UTC. Values are normalised to UTC on the way in, and so is a filter literal, so any accepted spelling of the same instant matches.

Node facts (GET and POST /workspace/{id}/storage/{node_id}/metadata/facts/ — the write answers in the read’s shape, so the tiers apply to both):

LevelFields returned on each item (cumulative)
tersefield, value
standardterse + declared_type, stored_type, source, confidence
fulleverything

🔴 A fact has TWO shapes, and which one you get depends on where you read it. They are identical at full and they diverge at every tier below it.

Both shapes carry the same eight item keys, in the same order, at ?output=full. Write your parser against one tier of one surface and it will break on the other: request full if you need one parser to serve both, or branch on which surface the block came from.

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 for the full contract.

Eligible nodes

GET /current/workspace/{workspace_id}/metadata/eligible/

Paginated list of files and notes eligible for metadata extraction — nodes whose AI summary is ready. A preview is not required: a file with a summary and no preview is eligible. Folders and links are excluded.

Each item carries the node’s extracted metadata values inline, so a workspace-wide metadata view renders from this listing alone — you do not need to follow each row with a per-node call.

Auth: Bearer token required. Workspace member. Metadata billing feature required.

Cost: none. Listing eligible nodes consumes no credits, however many pages you walk. Like every endpoint it is rate limited — read the x-ve-limit-* response headers rather than assuming a fixed budget.

Refusals on this endpoint are 406, not 400. An off-list category, a malformed parent_id and a cursor that does not match the request’s filters all return 406 with a machine-readable error.code. Branch on the status and on error.code, not on the message text.

ParameterTypeRequiredDescription
page_sizeintegerNoRecords per page on this endpoint. Three steps, in this order: anything above 250 is first reduced to 250; zero or a negative value then becomes 100 — the default, not the smallest size; the result is snapped to the nearest of 25, 100 or 250, never to an arbitrary value in that range. Boundaries for positive values: 62 or below snaps to 25, 63–175 snaps to 100, 176 or above snaps to 250 — a tie keeps the lower size, so 175 gives 100 and 176 gives 250. Those three are the only sizes this endpoint can return. The snap moves in both directions — ask for 50 and you get half what you asked; ask for 200 and you get more. Default 100. The response’s page_size field reports the size actually used. The snap is specific to this endpoint; a page_size elsewhere in the API is not necessarily quantized, so do not carry this rule across
cursorstringNoOpaque cursor from a previous response’s cursor. Treat it as meaningless text and send it back unchanged; omit for the first page
mimetypestringNoReturn only nodes with this MIME type
extensionstringNoReturn only nodes with this file extension
parent_idstringNoReturn only nodes whose parent folder is this one. Direct children only — the folder’s own contents, not everything beneath it. Takes a folder’s node id, or root for the workspace root — the same values each row’s own parent_id reports, so a value read off this listing can be sent straight back. trash is accepted and matches nothing, since trashed files are never eligible. An id naming no folder in this workspace also simply matches nothing; a value that is not a folder reference at all is refused with a 406. Sending it empty is the same as not sending it
categorystringNoReturn only nodes whose category is this value. Send one of Legal, Finance, Sales, Marketing, Engineering, Product, Operations, People, Research, Media, Personal, Otherspelled exactly as listed; finance is refused with a 406 that names the accepted values. Any other non-empty value is refused too, never quietly ignored, so a filter that comes back with results really did filter. Sending it empty is the same as not sending it. Matching against stored values is a separate question and is case- and accent-insensitive, so files stored under an older spelling of the same category are still found — legal, LEGAL and Légal are all returned by category=Legal. Whitespace is not folded: a value stored with surrounding spaces is a different value and is not returned. Adds a metadata_filter block to the response

Both narrowing filters are applied before the page is cut, so a page is short only when the workspace genuinely holds no more matching files — never because rows were dropped from a page after it was sized. They combine: sending both returns the files in that folder that also carry that category.

An empty value means “not sent”. ?parent_id= and ?category= return the unfiltered listing — not a 406, and not an empty page — and mimetype, extension and cursor behave the same way, so a client that always sends the key and templates an unset variable into it gets the whole listing. This is a different case from a wrong value: an empty category is no filter at all, while an off-list category is refused.

Filters are bound into the cursor. Changing parent_id, category, mimetype or extension while sending a cursor issued under different ones is refused with a 406 rather than silently answered with the old filters or with none. Drop the cursor and start the narrowed listing again.

Response (200 OK):

{
  "result": true,
  "count": 1,
  "page_size": 100,
  "cursor": null,
  "has_more": false,
  "items": [
    {
      "metadata_facts": {
        "count": 2,
        "total": 2,
        "is_truncated": false,
        "items": [
          {
            "field": "customer",
            "value": "Acme Corp",
            "declared_type": "string",
            "stored_type": "string",
            "source": "ai",
            "confidence": "high",
            "rationale": "Named in the invoice header block.",
            "updated": "2026-01-15 09:12:44 UTC"
          },
          {
            "field": "invoice_total",
            "value": 1420.0,
            "declared_type": "float",
            "stored_type": "float",
            "source": "ai",
            "confidence": "certain",
            "rationale": null,
            "updated": "2026-01-15 09:12:44 UTC"
          }
        ]
      },
      "node_id": "{node_id}",
      "parent_id": "root",
      "name": "invoice-2026-01.pdf",
      "mimetype": "application/pdf",
      "size": 40000,
      "summary_title": "Invoice #1234",
      "summary_short": "Invoice for services rendered in January 2026.",
      "templates": []
    }
  ]
}

metadata_facts is the first key of every row, ahead of node_id and ahead of templates, so a client that flattens a row by taking the first collection it finds lands on the live corpus. The same ordering holds at every ?output= level.

A fact here is byte-identical to a fact from GET /workspace/{id}/storage/{node_id}/metadata/facts/ at ?output=full, and NOT at the tiers below it. The two surfaces reduce differently — this one strips values first because it is a preview inside a listing row, the dedicated endpoint keeps them because values are all it returns — so one parser covers both only if it asks for full. See Compact Responses above for both shapes side by side. value is in its native JSON type; declared_type is what the field MEANS and stored_type is how it is held, and the two differ for url and datetime fields. source is one of ai, user, exif, mediainfo, validated_server. confidence is low, medium, high, certain, or null — null means “unknown or not applicable”, which is a real answer rather than a missing field. Facts carry no id of any kind: a field is identified by its name.

metadata_facts is always present. A node with no metadata returns {"count": 0, "total": 0, "is_truncated": false, "items": []} — never a missing key, never null — so every row can be rendered without branching. A read failure does not degrade this block; it fails the whole request, so count: 0 always means “this node has no metadata”, never “we could not tell”.

count is what the payload carries, not what the node holds — total is what the node holds. is_truncated says whether anything was left out; total says how much there was, counted before any cap, and is never less than count. Read the pair as “2 shown of 9”, and note that total is stable across ?output= levels where count is not. When is_truncated is true the node has more values than this response carries: fetch the complete set for that one file from GET /workspace/{id}/storage/{node_id}/metadata/facts/. The cap binds per node, so one metadata-heavy file never consumes another file’s share of the response.

Each node’s metadata_facts.items is 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 for a GIVEN file the order is deterministic, and the same facts survive a standard-tier cap on every read of that file. It is not a promise ACROSS files: two files with different fields legitimately surface different facts, so a column layout inferred from one page may need widening on the next. The outer items — the node list itself — is ordered newest-updated first, not by name.

parent_id is the folder this file currently lives in, as the same identifier you would use in a path. A file sitting directly in the workspace root reports the literal "root", and one in the trash reports "trash", matching the storage listing’s parent field exactly — so you can group an eligible listing by folder without a second call per row.

It reflects where the file is now, not where it was when its metadata was extracted, so a file that has been moved groups under its current folder. It is present at terse as well, because grouping a large listing by folder is exactly the job the light tier exists for.

metadata_filter appears only when you sent category, and it reports what that filter actually did:

{
  "metadata_filter": {
    "applied": true,
    "matched": 42,
    "truncated": false
  }
}

Those three keys are the whole block. There is no scope_incomplete here, unlike the metadata_filter the storage search publishes — a candidate lookup that fails ends this request instead of continuing with a partial set, so there is nothing partial to report.

Its presence means the filter ran, and nothing more. The block appears whenever you sent category, including when nothing matched at all ("matched": 0), and it is absent whenever you did not. So the response shape is a reliable answer to “did my category filter apply?” — but it is not a signal that anything was found.

matched is workspace-wide. It is NOT narrowed by parent_id, mimetype or extension. It counts every file in the workspace carrying that category, which is the pool the listing then pages through; the other filters cut that pool afterwards. So this response is correct and normal:

{ "count": 0, "items": [], "metadata_filter": { "applied": true, "matched": 4, "truncated": false } }

Four files in the workspace are Legal; none of them is in the folder you scoped to. count describes the page, matched describes the category pool, and they answer different questions — do not read matched as a promise that a narrowed listing will return anything.

truncated is the one to check: the pool is bounded at 1000 files, so on a workspace with more than that in one category it is capped and truncated becomes true. Then, and only then, the listing may omit matching files. Narrow further — add parent_id, mimetype or extension — rather than paging to the end and assuming you saw everything. When truncated is false the pool was complete and paging reaches every match.

Field vocabulary

GET /current/workspace/{workspace_id}/metadata/fields/

Paginated list of the field names this workspace stores metadata under, with the type and constraints governing each one. Use it to populate a field picker, or to tell a model which fields it may fill in — a field that cannot be enumerated cannot be named.

Fields are identified by name; there is no field id. A field that has been merged into another is not listed separately — its name appears in the surviving field’s aliases, so a name stored earlier still resolves. The vocabulary is per workspace: the same name in another workspace is an unrelated field.

This GET is read-only — it never creates a field, edits its name or type, or deletes one. Nothing edits a field’s name or type or deletes one anywhere. But fields DO get created, by three writers: POST .../metadata/fields/ below, declaring one explicitly and choosing its type; extraction proposing a name; and a node-facts write (POST .../storage/{node_id}/metadata/facts/) using a name this workspace has not seen — that creates the field and infers its type from the value. So a facts write consumes vocabulary capacity; do not assume you must wait for extraction before using a new name. The other write on the vocabulary itself is POST .../metadata/fields/merge/ below.

Auth: Bearer token required. Workspace member. Metadata billing feature required.

ParameterTypeRequiredDescription
page_sizeintegerNoRecords per page (1–250, default: 100)
cursorstringNoOpaque cursor from a previous response’s cursor. Treat it as meaningless text and send it back unchanged; omit for the first page

Response (200 OK):

{
  "result": true,
  "count": 2,
  "page_size": 100,
  "cursor": "invoice_total",
  "has_more": true,
  "items": [
    {
      "name": "author",
      "declared_type": "string",
      "constraints": null,
      "aliases": ["auth_or"],
      "provisional": false,
      "origin": "user",
      "revision": 3,
      "file_count": 128,
      "created": "2026-08-14 10:22:01 UTC",
      "updated": "2026-08-16 09:03:44 UTC"
    },
    {
      "name": "invoice_total",
      "declared_type": "float",
      "constraints": {"min": 0, "max": 1000000},
      "aliases": [],
      "provisional": true,
      "origin": "ai",
      "revision": 1,
      "file_count": 0,
      "created": "2026-08-16 08:00:00 UTC",
      "updated": "2026-08-16 08:00:00 UTC"
    }
  ]
}
FieldTypeDescription
namestringThe field’s canonical name — the key values are stored under
declared_typestringOne of string, bool, int, float, json, url, datetime
constraintsobject or nullDeclared limits (allowed, min, max, regex); null when unconstrained
aliasesarrayEarlier names that resolve to this field
provisionalbooleantrue while the field is a suggestion nobody has confirmed
originstringai if the field was proposed by extraction, user if a person created it
revisionintegerIncrements each time the field’s definition changes
file_countintegerHow many NODES hold a value for this field — files AND notes (see below). ABSENT — not 0 — when the count could not be read. Returned from standard upward
createdstringWhen the field definition was created (YYYY-MM-DD HH:MM:SS UTC)
updatedstringWhen the field definition last changed (YYYY-MM-DD HH:MM:SS UTC)

file_count is ABSENT rather than zero when it is unknown. A 0 is a confident claim that nothing uses the field — the sort of claim a cleanup or merge UI acts on — so a count that could not be read drops the key instead. Key present with 0 means genuinely unused; key missing means nobody counted, and the rest of the record is still good. It counts every file holding a value for the field, including nodes since trashed, and it is the same number the merge pre-flight reports as files_affected.

It counts NODES, not files, despite the name. Notes carry metadata too and are counted alongside files, and the count cannot tell the two apart — a workspace with ten annotated notes and no annotated files reports 10. The field name is pinned for compatibility, so read the meaning here rather than reading the name as a promise that files are all it covers.

Declare a field

POST /current/workspace/{workspace_id}/metadata/fields/

Adds a name to the workspace’s vocabulary before any file holds a value for it, and lets you choose the type rather than having one inferred.

This is what makes “add a column, then have AI fill it in” a single flow: scoped extraction (fields on the extract routes) only accepts names the vocabulary already holds, and a field declared here is accepted immediately — it does not need a value first.

The call is idempotent. A name already in use returns the existing definition instead of failing, and a name that was merged away returns the field that now governs it. field_created tells you which happened, so you can say “added” or “already there” without guessing.

Field names are compared case-insensitively: invoice total and Invoice Total are one field. Only the spelling stored first is kept, which is why the response echoes the stored definition rather than the name you sent — always render what comes back.

🔴 A declaration is permanent. Names are write-once and there is no delete: nothing in this API renames a field or removes it. Declaring a misspelled name leaves it in the vocabulary for good — the only remedy is POST .../metadata/fields/merge/ to fold it into the right one.

There is no constraints parameter. Constraints are reported by the listing but are not caller-settable here, and a value that would be silently discarded is refused rather than accepted.

Auth: Bearer token required. Workspace admin — a higher bar than the listing’s member, because a declaration is permanent for every member. Metadata billing feature required.

ParameterTypeRequiredDescription
namestringYesThe field name, up to 64 characters. Leading and trailing whitespace is trimmed before it is stored and validated; a name that is empty or only whitespace is rejected
declared_typestringYesOne of string, bool, int, float, json, url, datetime. Any other value is rejected — including type names used elsewhere in the platform that a field definition cannot store

field_created is a boolean; field.created is a timestamp. They are different keys at different levels and it is easy to read one for the other.

Refusals, and which of them are worth retrying. The error body carries code (a unique per-call-site identifier), text, documentation_url and params — there is no class or reason field, so the HTTP status is what tells you how to react:

StatusCauseWhat to do
406The name is blank, longer than 64 characters after trimming, refused by the name policy, or you sent constraintsThe request is wrong. Fix it — do not retry
413The workspace has reached its metadata field limitPermanent. Fold near-duplicates with .../metadata/fields/merge/ or raise the limit — do not retry
500The vocabulary write did not complete — most often a brief lock contention with a concurrent declaration or an in-flight extractionRetry with backoff. Nothing was created
429Rate limitedRetry after backoff, honouring the rate-limit headers

🔴 Do not branch on the code value. It identifies the call site that produced the error, not a category, and it changes when that handler is edited. Branch on the status; read text for a message to show.

field.origin is user for a field declared this way and ai for one proposed by extraction, so the listing can distinguish them afterwards. (provisional does not make that distinction — treat it as uninformative.)

Response (200 OK):

{
  "result": true,
  "field": {
    "name": "Invoice Total",
    "declared_type": "float",
    "constraints": null,
    "aliases": [],
    "provisional": true,
    "origin": "user",
    "revision": 1,
    "created": "2026-08-29 02:41:12 UTC",
    "updated": "2026-08-29 02:41:12 UTC",
    "file_count": 0
  },
  "field_created": true
}

Merge two fields

POST /current/workspace/{workspace_id}/metadata/fields/merge/

Folds one field into another: source stops being its own entry in the vocabulary and its name resolves to target from then on, turning up in the target’s aliases like any other retired name. One call folds exactly one source, and this API cannot undo it.

Both sides are named BY NAME — there are no field ids on this API.

The two sides are matched differently, and the asymmetry is deliberate. target is resolved the way a filter resolves a name, so naming a field an earlier merge retired lands on the field that name resolves to now. source is not: it must name a field that is still its own entry in the vocabulary. A retired name given as source is REFUSED (merge_source_already_merged) rather than quietly retargeted onto the field it resolves to — which would irreversibly retire a live field the caller never named.

Auth: Bearer token required. Workspace ADMIN — a higher bar than the listing’s member, because a fold retires a name from every member’s vocabulary permanently. Metadata billing feature required. Rate-limited more tightly than the listing, so debounce a dialog that re-previews as the user types; see the rate-limit headers in Rate Limiting.

confirm IS A BOOLEAN HERE, and that is unlike every other confirm in this API. On workspace delete, share delete and org close, confirm is a STRING that must equal the resource’s own name or id. Following that idiom here — sending the field name as confirm — is rejected with an invalid-input error naming the mistake. It is never read as truthy and it never performs the merge.

🔴 The accepted grammar depends on the ENCODING, and over JSON a string does NOT confirm.

Which grammar applies is decided from the request’s Content-Type, never from what the body looks like. A request declaring JSON whose body will not parse as a JSON object is an error and is never re-read as a form. Omitting confirm is the pre-flight, on either encoding.

confirmWhat happens
absent, or falsePre-flight. Nothing is written. Reports what the fold would do and whether it would be refused.
truePerforms the fold, synchronously, answering in the same shape. No job, nothing to poll.

The pre-flight is not a separate estimate — it takes the same lock and evaluates the same refusals in the same order, then rolls back before the first write. The VERDICT therefore cannot drift: the refusal a dialog shows is the refusal the confirm will reach.

The COUNTS are a weaker claim, and the difference matters. They are a reading taken while the merge holds its workspace lock, not a reservation. A metadata write landing between a pre-flight and a later confirm still moves them, and snapshot CANNOT detect that — its revisions and group size track changes to the vocabulary, not changes to the values. Treat the numbers as true at the moment they were read.

Parameters go in the request body — a JSON object or form-encoded fields. They are not read from the query string, deliberately: a query-string form of an irreversible write is clickable and lands in logs and referrers.

ParameterTypeRequiredDescription
sourcestringYesThe field to retire, by name. At most 64 characters
targetstringYesThe field it folds into, by name. At most 64 characters
confirmbooleanNotrue performs the fold; absent or false is a pre-flight. NOT a name

Request example:

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/metadata/fields/merge/" \
  -H "Authorization: Bearer {jwt_token}" \
  -H "Content-Type: application/json" \
  -d '{"source": "vendor", "target": "vendor_name", "confirm": false}'

Response (200 OK) — both modes answer this shape:

{
  "result": true,
  "source": "vendor",
  "target": "vendor_name",
  "files_affected": 128,
  "target_files_before": 940,
  "would_refuse": null,
  "merged": false,
  "already_merged": false,
  "snapshot": {
    "source_revision": 3,
    "target_revision": 7,
    "group_size": 2
  }
}
FieldTypeDescription
sourcestringThe field retired (or that would be), named as the vocabulary stores it — NOT resolved through a fold, see below
targetstringThe field it folds into, by its canonical name
files_affectedintegerHow many NODES hold a value for source — files AND notes — counted before the fold
target_files_beforeintegerHow many NODES hold a value for target — files AND notes — counted before the fold
would_refuseobject or nullnull when the fold would succeed (or already has); otherwise {reason, message}
mergedbooleantrue only when THIS call performed the fold
already_mergedbooleantrue when the two names already resolve to one field — nothing to do
snapshotobject or null{source_revision, target_revision, group_size} when the group was locked and walked; null otherwise

Neither name is a plain echo, and the two are settled differently. target comes back CANONICAL — pass a name an earlier merge retired and target names the field it resolves to now, not what you typed. source comes back as the STORED SPELLING of the field carrying that name (names match case-, accent- and width-insensitively, so catégory returns as category when that is how it is stored), and never as some other field it resolves to — a source that resolves elsewhere is refused instead. Either side returns EXACTLY as sent when no field matches at all. Build a confirmation from what came back, not from what you sent.

Names are never trimmed, and a leading space is significant. " author" and "author" are different fields, and the one you send is the one that is looked up. The 64-character limit is measured on what you send, padding included, so a padded over-length name is rejected rather than shortened into a name that would match a different field. A name that is only whitespace is rejected as blank.

snapshot is present only where the group was actually locked and walked, so it is null on every refusal, and null on an already_merged answer where the two names had already collapsed to one field before anything was locked. group_size is how many field definitions the fold locked and guarded — the source, the target, and any names already folded into either — so anything above 2 means one side already carries aliases, which is when a pre-flight’s counts are most likely to move underneath it. The revisions are what the guards were evaluated against, read before the fold’s own increment: on a call that folded, the source field’s stored revision is one higher than the source_revision reported.

A fold does NOT rewrite the values stored under the source field. They stay filed under the retired field, which nothing resolves to any more — so files_affected is “how many nodes’ values for this field stop being reachable by name”, not a count of values that move. That is what the confirmation is about. A filter on the retired name now resolves to the target and will not find them.

Refusals: would_refuse.reason is a STRING clients branch on, never a numeric code; the message is prose and may be reworded.

reasonMeaning
merge_refused_type_mismatchThe two fields hold values of different declared types, so folding them would mis-read half of them
merge_refused_user_authoredA value was entered by hand on one of them, and a hand-entered value is never folded away automatically
merge_refused_reserved_nameThe source is a reserved classification field (category, sub_category), which is never folded into another field
merge_source_unknownNo field in this workspace is named as given by source
merge_source_already_mergedThe field named as source has already been merged into another field, so it no longer names a field of its own and cannot itself be merged
merge_target_unknownNo field in this workspace resolves to the name given as target

merge_source_already_merged carries a THIRD key. Only this reason adds source_resolves_to to the would_refuse object — the name the source now resolves to — so a caller can retry immediately with the field it actually meant. It is null in the rare case that field has no usable name to report, and the message drops its retry suggestion to match. It is the refusal an interactive picker never hits and an API or agent caller reaches easily: the vocabulary listing only ever offers CANONICAL names (a retired name appears inside another field’s aliases, never as an entry of its own), so a picker cannot select one — but a caller working from a remembered name, or from a name read before someone else’s fold, sends one readily.

A refusal is HTTP 200 in BOTH modes — a refused confirm: true answers exactly as a pre-flight does, with merged: false, and performs nothing. Read would_refuse and merged, never the status code. Source-side refusals are reported before target-side ones, and an unknown source before an already-merged one, because a caller fixes one name at a time and the source is the destructive side. On all three name-side refusals nothing was counted, so files_affected and target_files_before are 0 because no pair of fields was found — not because the fields are empty.

Errors:

HTTPCodeReason
4061605source or target missing, not a string, blank or over 64 characters; confirm present but not a boolean; or a permanent refusal with no published reason (“These two fields cannot be merged.”)
5031693The vocabulary is busy, or the file counts could not be read. Nothing was written — back off briefly and resend unchanged

A count that cannot be read fails the request rather than reporting zero: the counts are the entire content of a confirmation, and a fabricated 0 would say an irreversible fold touches nothing.

Merge candidates

GET /current/workspace/{workspace_id}/metadata/fields/merge-candidates/

Which of this workspace’s fields are one name written twice — pairs whose names differ only in punctuation and capitalisation, like file_type and File Type. Each pair arrives already oriented as the source and target the merge endpoint above takes, so a result can be handed straight to it.

It exists because the merge endpoint has no other way to be offered safely. A picker listing every field in the workspace invites exactly the sound-alike fold that cannot be undone, and aliases answers the opposite question — those are names a merge has already absorbed.

🔴 This is a punctuation-and-case check, not a duplicate detector. It does NOT find abbreviations (invoice_no / invoice_number), synonyms (vendor / supplier), typos, or plurals (tag / tags). Those are excluded deliberately rather than unimplemented: the rule that pairs tag with tags also pairs term with terms, minute with minutes and damage with damages, which are different fields — and a rule that pairs one-character neighbours proposes approved with approver and contract with contact. A wrong proposal costs a destroyed field; a missing one costs a search. Describe these to a user as “the same letters written differently”, never as “duplicates”.

🔴 An empty list has three different meanings. Read the counts before reporting any of them.

What you seeWhat it meansWhat to do
vocabulary_scanned: falseThe comparison did not run — the workspace holds more fields than it will scanReport “not computed”, never “nothing found”
proposals_examined < proposals_totalPairs were found, but this call only checked some of them and every one it checked was refused by the mergeCall again with offset set to next_offset
proposals_examined == proposals_totalEverything found was checkedThis is the only empty list that means nothing is mergeable

Even the third case is not a clean bill of health. Every pair must also survive the merge’s own refusals, which include any field carrying a hand-entered value. A workspace where people have typed values by hand, or whose metadata arrived from an earlier system, can report nothing and still hold plenty of near-duplicate names — and it is exactly that workspace whose top-ranked pairs get refused, which is why next_offset matters there most.

Auth: Bearer token required. Workspace ADMIN — the same bar as the merge it feeds, rather than the listing’s member, because a proposal shown to somebody who cannot act on it is only a suggestion to ask an admin for an irreversible favour. Metadata billing feature required. Rate-limited well below the vocabulary listing, because every pair offered is checked against the real merge first; debounce accordingly and see the rate-limit headers in Rate Limiting.

Parameters:

ParameterTypeDescription
offsetintegerOptional, default 0. Rank to begin checking from — send the next_offset of a previous response to see further down the list. Values past the end are clamped, not rejected.

The comparison always covers the whole workspace; what offset moves is which slice of the ranked pairs this call spends its checking budget on. There is deliberately no page-size parameter: the number checked per call is fixed server-side, because every pair offered is verified against the real merge first.

Response (200 OK):

{
  "result": true,
  "count": 2,
  "vocabulary_scanned": true,
  "fields_scanned": 218,
  "proposals_total": 2,
  "proposals_examined": 2,
  "next_offset": null,
  "items": [
    {
      "source": "File Type",
      "target": "file_type",
      "reason": "separator_or_case_variant",
      "direction_certain": true,
      "files_affected": 3,
      "target_files_before": 190
    },
    {
      "source": "Invoice Total",
      "target": "invoice_total",
      "reason": "separator_or_case_variant",
      "direction_certain": false,
      "files_affected": 7,
      "target_files_before": 7
    }
  ]
}
FieldTypeDescription
countintegerPairs in items
vocabulary_scannedbooleanfalse when the comparison did not run because the workspace holds too many fields — see below
fields_scannedintegerFields compared to produce this answer; 0 when vocabulary_scanned is false
proposals_totalintegerPairs the comparison found across the whole workspace, before any were checked against the merge
proposals_examinedintegerHow many of those this call checked, counted from the top of the ranking
next_offsetinteger or nullSend as offset to check further down the list; null when every proposal has been checked
items[].sourcestringThe field a merge would retire — send as source
items[].targetstringThe field it would fold into — send as target
items[].reasonstringWhich rule proposed the pair. Currently always separator_or_case_variant; branch on it rather than assuming it, so a rule added later arrives as a value to ignore
items[].direction_certainbooleanfalse when nothing says which of the two spellings to keep — see below
items[].files_affectedintegerNodes holding a value for source, the same number the merge pre-flight reports
items[].target_files_beforeintegerNodes holding a value for target, the same number the merge pre-flight reports

vocabulary_scanned: false means “not computed”, NOT “nothing found”. A workspace with more fields than the comparison will scan is answered with an empty list and this flag, rather than with pairs chosen from part of its vocabulary — where the better survivor for a pair could be a field the scan never reached. Read the flag before reporting a clean result.

direction_certain: false is not a warning that the pair is wrong. The two names are still the same letters; it means the two are used equally and nothing in the data says which spelling should survive. Both orientations are valid merges, and the choice belongs to whoever is asked. When it is true, the surviving field is the one more files use — or the classification field category / sub_category, which always survives, because a merge refuses to retire one.

🔴 direction_certain never contradicts the counts printed beside it. files_affected and target_files_before are the counts taken at the moment the pair was verified, and the flag is judged against THOSE numbers rather than against an earlier reading — so a row will never tell you to retire the field holding more files. When the verified counts REVERSE the orientation the pair was given, it is left out of the answer entirely rather than published with a direction its own numbers contradict; when they LEVEL it, the pair is still returned, with direction_certain: false, and the choice of spelling is yours rather than the API’s.

Certainty is only ever LOWERED by that check, never raised. A pair the rules could not decide stays undecided however the counts fall, so direction_certain: true always means a rule decided the direction — it is never an artefact of the second reading.

A reserved classification field named as target is the one exception. No count decided that direction: category and sub_category always survive because a merge refuses to fold one away, so the other orientation cannot be performed at any counts. Such a row keeps direction_certain: true however the counts fall, including when files_affected is the larger of the two.

A pair the verified counts turned around is not gone for good — it is simply absent from THIS answer, and a later call re-reads the counts and offers it oriented the other way. Because the list is a reading rather than a reservation, re-read the candidates before acting if the answer has been sitting on screen.

Every pair is pre-checked against the real merge, in the direction given, so a candidate is one the merge is expected to perform rather than merely one that looks foldable. That check is a reading at a moment, not a reservation — still send the merge pre-flight before confirming, because someone else’s write can change the answer in between.

The pairs never chain. No field is offered as a source twice, and no field is both a source and a target, so the merges can be performed in any order or partially. Folding one pair changes the vocabulary, so ask again afterwards for a fresh, re-ranked list.

Asking again is not how you see more of the list. A repeat call re-ranks the same vocabulary and returns the same slice; use offset / next_offset to move down it. offset is an ordinal into a ranking the server re-derives on every call, not a stable cursor — a workspace that changes between calls can re-rank, so a walk may repeat or skip a proposal. It can never show you a pair the merge would refuse: every pair returned is checked at the moment it is returned.

Ordering. Pairs with a certain direction first, then the fewest files_affected — the least destructive merge is offered first — then the most target_files_before, then by source.

Errors:

HTTPCodeReason
4061605The candidates could not be listed for this request
5031693The vocabulary is busy, or the file counts could not be read. Back off briefly and resend

A busy workspace fails the request rather than returning the pairs that happened to check out. A list that is short because the vocabulary was busy is indistinguishable from a vocabulary that is nearly clean, and only one of those is safe to act on.

Node Metadata

Metadata stored on individual files, as metadata_facts — typed values, each carrying the source that set it. This is the corpus every metadata read, filter and search answers from, and the only one you should build against.

One narrow exception, so it does not surprise you: GET .../storage/{node_id}/metadata/list/ still returns older key/value rows under a generic metadata key. Those are the pre-facts corpus, kept readable so nothing written before the migration becomes unreachable. They are not metadata_facts, nothing filters or searches them, and no new value is ever written there.

🔴 The older template_metadata and custom_metadata response blocks have been WITHDRAWN. They split the same idea in two — values stored against a template, and everything else — and no Fastio endpoint returns either key any longer. The route that wrote them, POST .../storage/{node_id}/metadata/update/, is retired and answers 410 Gone. If your client still reads those keys, or branches on their absence, that code is dead: treat a response without them as normal, not as an empty file or an error.

Why a file has no metadata (extraction)

An empty fact list used to mean two different things at once — nothing had ever tried to extract this file, or an extraction ran and failed — and nothing in the response told them apart. The extraction block does.

Two reads carry it, as a top-level key alongside the keys they already returned: GET and POST .../storage/{node_id}/metadata/facts/, and the single-node form of GET .../storage/{node_id}/metadata/details/. It survives every ?output= tier unchanged.

{
  "result": true,
  "object_id": "aBcDeFgHiJkLmN",
  "count": 0,
  "items": [],
  "extraction": {
    "state": "failed"
  }
}
stateWhat it means
extractedThe file holds metadata, or an extraction ran to completion and found nothing. Either way it worked.
failedAn extraction reached a terminal failure, or the file was refused before extraction ever ran — an unsupported format, over the size limit, or in the trash at the time — and the file has no metadata.
in_progressAn extraction has been dispatched for this file and has not settled yet.
never_attemptedNothing has ever tried to extract this file.

🔴 state is an OPEN-ENDED value set — a value you do not recognise is unknown, and must NOT be an error. New states may be added, and adding one is explicitly not a breaking change. Branch on the values you know and let anything else fall through to a neutral “unknown” rendering; a client that switches exhaustively over the four above and fails closed on a fifth has mis-implemented this contract.

🔴 extraction is OMITTED when the state could not be read, and an absent block means “not reported” — NEVER never_attempted. never_attempted is a positive claim that nothing has ever tried this file. A block that is not there makes no claim at all, in either direction. Test that the key is present before reading state, and treat its absence the way you treat a degraded metadata_facts block: ask again later rather than caching it as an answer.

There is no failure cause, deliberately. failed says an extraction ended badly; nothing on this surface says why, and no field carries a reason string. A failed file may have been refused before extraction ever ran — for its format, its size, or because it was in the trash — and re-running it returns the same answer. Re-run only after the file itself has changed: a new version, a supported format, or restored from the trash.

extracted is not a promise that items is non-empty, and that is the point of the pairing: a completed extraction that found nothing to record reports extracted with count: 0, which is a different answer from the same empty list under failed. Read the two keys together.

Get file metadata

GET /current/workspace/{workspace_id}/storage/{node_id}/metadata/details/

Returns everything Fastio knows about one file’s metadata.

The response carries one set of values, metadata_facts, alongside the pointers that identify what it belongs to.

KeyWhat it isStatus
metadata_factsExtracted and user-corrected field values for this fileLive. This is the corpus.

metadata_facts is the first key of the payload, and that ordering is part of the contract. A client that flattens the response by taking the first metadata collection it finds in key order lands on the values. Fastio will not move it.

When Fastio cannot read the facts, the key stays put and says so. A key that simply vanished on a bad day would be indistinguishable from a file holding nothing, so a fact read that fails returns the key with an explicit marker instead:

{
  "result": true,
  "metadata_facts": {
    "unavailable": true,
    "reason": "read_failed"
  }
}

Test for it with metadata_facts.unavailable. It is present only on the degraded block — a healthy block never carries the field — so if (metadata_facts.unavailable) is the correct check in both cases. Do not test for an available flag; there isn’t one, and its absence on a healthy block would make the inverted check misreport every good response.

The degraded block shares no field with a healthy one: no count, no total, no items, no is_truncated. That is deliberate. "count": 0 is a positive statement that the file has no extracted metadata, which a read that failed has no standing to make and which you may safely cache; the marker makes no statement at all. Treat it as “ask again later”, never as “this file has none”.

The response is still 200, and everything else in it — the file pointer, the template id, the extraction flag — was read successfully and is complete. Only the facts are missing. The marker survives every ?output= tier unchanged, so a terse or standard response reports it in the same shape rather than reducing it to an empty field list.

What the key can carry. Three of these are states of this endpoint; the fourth is what a different endpoint’s silence looks like, listed so the two are never confused:

What you receiveWhat it means
"metadata_facts": {"count": 2, "total": 2, "is_truncated": false, "items": [...]}These are the file’s facts. count is how many are in this payload; total is how many the file holds; is_truncated says whether more exist.
"metadata_facts": {"count": 0, "total": 0, "is_truncated": false, "items": []}This file genuinely has no extracted metadata. A positive answer, safe to cache.
"metadata_facts": {"unavailable": true, "reason": "read_failed"}Fastio could not read them this time. Not an answer about the file — retry.
the key is absent entirelyThis endpoint does not serve facts at all — see Where metadata_facts is and is not served below. It is saying nothing about the file.

Auth: Bearer token required. Workspace member. Metadata billing feature required.

Request example:

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/aBcDeFgHiJkLmN/metadata/details/" \
  -H "Authorization: Bearer {jwt_token}"

Response (200 OK):

{
  "result": true,
  "metadata_facts": {
    "count": 2,
    "total": 2,
    "is_truncated": false,
    "items": [
      {
        "field": "category",
        "value": "Finance",
        "declared_type": "string",
        "stored_type": "string",
        "source": "ai",
        "confidence": "high",
        "rationale": "The document is headed with an invoice number and a payment due date.",
        "updated": "2026-08-26 15:00:47 UTC"
      },
      {
        "field": "invoice_total",
        "value": 1500.00,
        "declared_type": "float",
        "stored_type": "float",
        "source": "user",
        "confidence": null,
        "rationale": null,
        "updated": "2026-08-26 16:12:03 UTC"
      }
    ]
  },
  "instance_id": "1234567890123456789",
  "object_id": "aBcDeFgHiJkLmN",
  "template_id": "mt_oPqRsTuVwXyZ12",
  "node_id": {
    "id": "aBcDeFgHiJkLmN",
    "name": "invoice_2025.pdf",
    "type": "file",
    "size": 245760
  },
  "autoextractable": true,
  "extraction": {
    "state": "extracted"
  }
}

Those seven keys are the whole response — six of them always, plus extraction, which is omitted when Fastio could not read the file’s extraction state.

FieldTypeDescription
response.metadata_factsobjectThe fact corpus for this file: count, total, is_truncated, and items. Item shape matches the node facts endpoint at ?output=full; below that the two reduce differently — see Compact Responses above. Never null. When the facts could not be read for that request the key is still there, carrying the degraded marker {"unavailable": true, "reason": "read_failed"} instead; see metadata_facts below
response.instance_idstringThe owning workspace’s id
response.object_idstringThe node’s id, echoed back
response.template_idstring/nullAssociated template ID, or null. Templates are retired, so read it as historical provenance — it is inert
response.node_idobjectStorage node resource with file details
response.autoextractableboolean/nullWhether the file is eligible for automatic extraction
response.extractionobjectWhy this file has or has no metadata: {"state": …}, one of extracted, failed, in_progress, never_attempted — an open-ended set. Single-node form only (the bulk comma-separated-ids form does not carry it), and omitted when the state could not be read, which means not reported rather than never_attempted. Survives every ?output= tier. See Why a file has no metadata above

metadata_facts

Empty versus degraded. A file with no facts returns {"count": 0, "total": 0, "is_truncated": false, "items": []} — a positive statement that the file has none. If the block carries unavailable, Fastio could not vouch for the answer (the metadata read did not complete). Treat the marker as unknown, not as none, and retry rather than caching it. The rest of the response is still owed to you and is still correct, so a degraded block does not fail the request.

The same block also appears nested inside the node_id sub-object, because every storage node projection carries it. That nested copy is the node’s field, not a second API — read the top-level key. The nested copy keeps the older rule and is omitted entirely rather than degraded when it cannot be read, so the marker is a top-level thing only.

Where metadata_facts is and is not served

metadata_facts is served by the endpoints built for it: the per-file metadata details call (single and multi-id) and the workspace eligible-nodes listing.

It is deliberately absent from two other places, and the absence is a contract rather than an omission — in each case the response is saying nothing about the file’s facts either way:

These are absences, not the degraded marker above — a missing key is a refusal to answer, while the marker means Fastio tried and failed.

The withdrawn key/value sets

template_metadata and custom_metadata carried the older key/value model — one for values stored against a template, one for everything else. Neither key is returned by any endpoint now, and the route that wrote them answers 410 Gone (see Update file metadata below). Historical values that were migrated forward read back as ordinary facts. The rest are still reachable, just not under those two keys: GET .../storage/{node_id}/metadata/list/ returns them under a generic metadata key, and the metadata version history replays stored snapshots in the old {key, value, value_type, is_auto} shape. Neither is a filterable or searchable corpus — read them to recover a historical value, not to build against.

Two habits from that model do not carry over. is_auto is not a fact field — a fact records source instead (ai, user, exif, mediainfo, validated_server), written at the time of the write rather than derived, so a client testing is_auto on a fact is testing a key that is not there. And there is no second corpus to reconcile: no response carries two sets, so there is no precedence ladder to run between them and no de-duplication for you to perform. Read metadata_facts and take it as the answer.

A value a person wrote is never replaced by extraction. A hand-written fact carries source: "user", which outranks the ai an extraction writes, so extraction skips that field entirely — the stored value stands, nothing is written, and no second copy of the field is created beside it. Re-running extraction over a field you have curated therefore cannot overwrite it, and a 200 from an extraction job does not mean every field in scope was rewritten.

🔴 ONE thing hands a curated field back to extraction, and it is the DELETE:

No write reopens a field. A null sent to metadata/facts/ is a complete no-op — it clears nothing and creates nothing — so it cannot produce the empty row that used to invite extraction back. And "", 0 and false are worse than a no-op for this purpose: unchecking a box, or setting a count to zero, is an answer a person chose rather than the absence of one, so each stores at source: "user" and pins the field permanently beyond extraction’s reach. The route that once cleared a field by writing null was metadata/update/, which is retired; that behaviour went with it. If you mean “I have no answer for this field”, delete it.

Two consequences of deleting are worth designing around. Removing a value does not remove the field: the name stays in the workspace vocabulary, still resolves for a later write, and still counts against the plan’s field cap — the vocabulary has no delete. And a delete does not by itself queue a re-extraction: a full re-extract of a file whose current version was already extracted by the current extraction version answers already_extracted and queues nothing, so name the field in a fields-scoped metadata/extract/ call when you want it filled again.

Bulk Form

The metadata 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}/metadata/details/

Up to 25 ids per call. Duplicate ids are silently deduplicated. Empty segments (e.g. trailing comma) return 400.

Each resolved object carries its own metadata_facts block, first, exactly as the single-id form does — including the empty-versus-degraded rule. The facts for a page are read as one batch, so a read failure degrades the page: every object’s block carries the unavailable marker rather than the page failing or any object silently reporting count: 0. Test each object’s own block; do not assume the whole page is healthy because the first one is.

The bulk form does NOT carry extraction. That block is on the single-id form only, so a bulk object never reports why a file has no metadata — and its absence there is the ordinary “not reported”, not never_attempted. Ask for the ids you need the state of one at a time, or read it from .../metadata/facts/.

The bulk response shape differs from the single-id form. Successfully resolved objects appear under objects; per-id failures appear in a parallel errors array. Template definitions are hoisted to a top-level templates map keyed by template_id so a template shared by N objects is returned once instead of N times — clients look up each object's template via the template_id it carries. HTTP status is 200 when at least one object resolves and 404 when every requested id errored.

curl Example

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/abc123,def456,ghi789/metadata/details/" \
  -H "Authorization: Bearer {jwt_token}"

Response (HTTP 200)

{
  "result": true,
  "format": "multi",
  "objects": [
    {
      "metadata_facts": {
        "count": 1,
        "total": 1,
        "is_truncated": false,
        "items": [
          { "field": "category", "value": "Finance", "declared_type": "string", "stored_type": "string", "source": "ai", "confidence": "high", "rationale": null, "updated": "2026-08-26 15:00:47 UTC" }
        ]
      },
      "instance_id": "1234567890123456789",
      "object_id": "abc123",
      "template_id": "mt_oPqRsTuVwXyZ12",
      "node_id": { "id": "abc123", "name": "invoice_2025.pdf", "type": "file" },
      "autoextractable": true
    },
    {
      "metadata_facts": {
        "count": 0,
        "total": 0,
        "is_truncated": false,
        "items": []
      },
      "instance_id": "1234567890123456789",
      "object_id": "def456",
      "template_id": "mt_oPqRsTuVwXyZ12",
      "node_id": { "id": "def456", "name": "invoice_2026.pdf", "type": "file" },
      "autoextractable": true
    }
  ],
  "templates": {
    "mt_oPqRsTuVwXyZ12": {
      "template_id": "mt_oPqRsTuVwXyZ12",
      "name": "Invoice",
      "fields": [
        { "name": "invoice_number", "type": "string", "description": "Invoice number" }
      ]
    }
  },
  "errors": [
    { "node_id": "ghi789", "code": 191049, "message": "Storage node not found" }
  ]
}

errors is always an array (possibly empty); templates is always an object/map (possibly empty {}).

Request-level Errors (whole request fails)

Error CodeSub-codeHTTP StatusDescription
1605 (Invalid Input)160655406Empty segment between commas
1605 (Invalid Input)109184406More than 25 unique ids in one request
1609 (Not Found)404Every requested id errored (errors array is populated)

Per-id Error Codes (inside each errors[] entry)

The request itself is HTTP 200 unless every id errored. Each entry in errors[] carries one of:

CodeMeaning
147196Invalid storage node id format
196136The literal root sentinel was supplied (only files/notes are valid)
191049Storage node not found
190770Backend error retrieving the storage node (any non-not-found failure)
150183Storage node exists but is not a file or note (e.g. a folder)
157684Backend failure retrieving the metadata key/value rows

Get node metadata facts

GET /current/workspace/{workspace_id}/storage/{node_id}/metadata/facts/

Reads every metadata fact stored on one file or note, each joined to its canonical field name. This is the per-node counterpart of the field vocabulary (which lists a workspace’s field NAMES) — this endpoint lists the typed VALUES one node actually holds. Not paginated: the response returns every fact on the node in one call.

Auth: Bearer token required. Workspace member. Metadata billing feature required.

ParameterTypeRequiredDescription
outputstringNoDetail level for each item: terse, standard, or full (default). See Compact Responses above.

Response (200 OK):

{
  "result": true,
  "object_id": "aBcDeFgHiJkLmN",
  "count": 3,
  "items": [
    {
      "field": "author",
      "value": "Jane Doe",
      "declared_type": "string",
      "stored_type": "string",
      "source": "ai",
      "confidence": "high",
      "rationale": "Identified from the document byline",
      "updated": "2026-08-16 09:03:44 UTC"
    },
    {
      "field": "invoice_total",
      "value": 1250.5,
      "declared_type": "float",
      "stored_type": "float",
      "source": "user",
      "confidence": null,
      "rationale": null,
      "updated": "2026-08-16 10:15:00 UTC"
    },
    {
      "field": "source_url",
      "value": "",
      "declared_type": "url",
      "stored_type": "string",
      "source": "user",
      "confidence": null,
      "rationale": null,
      "updated": "2026-08-16 11:00:00 UTC"
    }
  ],
  "extraction": {
    "state": "extracted"
  }
}
FieldTypeDescription
object_idstringThe node’s id, echoed back
countintegerNumber of entries in items
extractionobjectWhy this file has or has no metadata: {"state": …}, one of extracted, failed, in_progress, never_attempted — an open-ended set, so treat an unrecognised value as unknown rather than as an error. Omitted when the state could not be read, which means not reported and never never_attempted. See Why a file has no metadata above
items[].fieldstringThe field’s canonical name — there is no fact id and no field id, only the name
items[].valuemixedThe value in its native JSON type — a number for an int/float fact, a boolean for a bool fact, a string for string/url facts, the canonical Y-m-d H:i:s UTC string for a datetime fact, or the decoded structure for a json fact
items[].declared_typestringWhat the field MEANS: one of string, bool, int, float, json, url, datetime
items[].stored_typestringThe storage column the value actually landed in: one of string, int, float, bool, date, json. Differs from declared_type in two cases, in different columns: a url field stores as string (the text column), a datetime field stores as date (a real datetime column) — both are reported so a disagreement is visible
items[].sourcestringProvenance: one of ai, user, exif, mediainfo, validated_server
items[].confidencestring or nullExtraction confidence band, one of low, medium, high, certain; null means unknown or not applicable
items[].rationalestring or nullOptional model justification for an AI-extracted value; null when none
items[].updatedstringWhen this fact was last written (YYYY-MM-DD HH:MM:SS UTC)

A cleared field is not the same as one that was never extracted. Clearing a field writes a fact with an empty value: for a text field value: "" and source: "user", and it still appears in items. A field nobody has ever extracted has no row at all — it is simply absent from items. Absence means “no fact”, not “empty fact”.

Write node metadata facts

POST /current/workspace/{workspace_id}/storage/{node_id}/metadata/facts/

Writes metadata facts a PERSON asserts on one file or note — the write half of the resource the GET above reads. Every value stored here is recorded with source: "user", which OUTRANKS ai, exif and mediainfo, so a later automatic re-extraction of the same file finds the human value in place and leaves it alone. This is how you CORRECT a wrong extracted value. Before it, a wrong value could only be deleted, never replaced with the right one.

Auth: Bearer token required. Workspace member. Metadata billing feature required — the same bar as the GET on this path. Reads and writes on this path are rate-limited on SEPARATE allowances, so polling the read never consumes a save’s; see the rate-limit headers in Rate Limiting.

Parameters go in the request body as a JSON object.

ParameterTypeRequiredDescription
factsobjectYesJSON object mapping field NAME to the value being asserted. At most 100 entries
outputstringNoDetail level for each item in the response: terse, standard, or full (default). See Compact Responses above.

facts must be a JSON object. A JSON array is refused, and so is the empty object {} — name at least one field.

🔴 A null value is a COMPLETE no-op: it clears nothing and it creates nothing. Null entries are dropped before the request resolves a single field, so a null neither overwrites the value a field already holds nor brings a field into existence for a name this workspace has never seen. Read the field back after a null write and you get the previous value, unchanged, with its previous source; read your field list back and it has not grown. This is by design, so that a partial payload — a form echoed back with its untouched cells left null — can never silently wipe a field, spend a field slot, or invent vocabulary the caller did not ask for.

If EVERY entry in facts is null the request writes nothing at all — no value, no field definition, no change event — and answers 200 with the file’s stored metadata exactly as it already stood. That is the same body a GET on this path would have returned.

Field NAMES are still checked on a null entry: {"facts": {"__proto__": null}} is refused rather than quietly ignored, so an unusable name can never ride along unmentioned inside a payload whose other entries do save.

To remove a value, use DELETE /current/workspace/{workspace_id}/storage/{node_id}/metadata/ with the field name in keys. Setting a value and clearing one are deliberately different calls.

If you are migrating from the retired metadata/update/ route, null is the behaviour that does NOT carry over. There, a null cleared the field and handed it back to extraction. Here it does nothing at all, and no other value reproduces the old effect — a clear becomes a DELETE. Code that sent null to blank a cell will now succeed while changing nothing.

Field names are never trimmed or normalised, but they ARE matched case- and accent-insensitively. An inner space is a perfectly good name character, so invoice total is a valid field name, and the 64-character limit is measured on what you send, padding included.

🔴 Author and author are the SAME field, not two. Names are compared without regard to case or accents, so a value sent as author lands on an existing Author rather than creating a second field. Sending both spellings in one request is refused as a duplicate — the request fails and neither value is written — rather than applied twice or silently collapsed to whichever came last. Pick one spelling per request.

🔴 The one exception: a name with LEADING or TRAILING whitespace is REFUSED, not accepted and not silently trimmed. " author" and "author " are both rejected with reason: field_name_not_canonical; send the name without the padding. The refusal exists because a padded name used to resolve onto the field of the same trimmed name and overwrite a value the request never named and could not see in its own payload. A refusal costs you one round trip; the overwrite cost the value. Nothing legitimate is blocked — no stored field name can carry surrounding whitespace, so no existing field is unreachable this way.

Request example:

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/aBcDeFgHiJkLmN/metadata/facts/" \
  -H "Authorization: Bearer {jwt_token}" \
  -H "Content-Type: application/json" \
  -d '{"facts": {"invoice_total": 1250.50, "vendor_name": "Acme Supply", "reviewed": true}}'

A name this workspace already uses keeps that field’s declared type; a name it has never seen creates a new field. A canonical name and a retired one both resolve to the field that governs the name now, so a value sent under a name an earlier merge folded away lands on the surviving field. For a genuinely new name there is no declaration to check against, so the type is inferred from the value’s JSON type:

You sendThe new field is declared
a stringstring — including a date-shaped one. A string is never inferred as datetime
a numberint or float
true / falsebool
an object or an arrayjson
nullnothing is created — the entry is dropped, so no field is declared and no value is written

The inference is deliberately shallow: guessing datetime or url from text would declare a constraint you never asked for and hold every later write to that field to it. If you need a field to be one of those types, that declaration has to already exist.

🔴 ALL-OR-NOTHING. The whole payload is validated before anything is written, and no metadata VALUE is ever written by a refused request — not the offending entry, and not the usable ones beside it. That guarantee is unconditional. A refusal is a refusal, and the response names every offending field, so a partial save is not something you have to detect by diffing your request against what came back. (The retired metadata/update/ route behaved the other way — it SKIPPED a field it could not coerce, named each one in a skipped array, applied the rest, and still answered 200. Code written to inspect skipped has nothing to inspect here; check for a refusal instead.)

🔴 A refused write changes nothing — including your FIELD LIST. The guarantee covers the field DEFINITIONS the request would have created, not only its values. Naming a field this workspace has never seen creates it, and a request that names two new fields but is refused on the second one leaves neither behind. That matters because a workspace’s field list is capped by its plan and a field definition cannot be deleted through the API: a partially-applied write would have spent a slot on a request that returned an error, with nothing able to reclaim it.

It holds for every way the request itself can be refused — an unusable field name, a value that will not fit its field’s declared type, two names resolving to one field, a field whose declared type moved underneath the request, the plan’s field limit, and the workspace being momentarily busy. (The one error that is NOT a refusal — a write that lands and then cannot be read back — is called out under Errors below.)

The corollary is that retrying is safe. A refused write leaves the file and the workspace exactly as it found them, so fixing the offending entry and sending the whole payload again is the intended recovery — there is nothing to clean up first, and no half-created field to reuse or dispose of.

A workspace at its field limit answers with a PLAN-LIMIT error, not a validation error — a different status and a different code, so the two never need telling apart from the message text. Merge two spellings of one field together, or raise the plan, then retry. Values written to fields that already exist are never refused by that limit.

Response (200 OK) — exactly the shape the GET on this path returns, so you confirm the stored state rather than assuming it:

{
  "result": true,
  "object_id": "aBcDeFgHiJkLmN",
  "count": 3,
  "items": [
    {
      "field": "invoice_total",
      "value": 1250.5,
      "declared_type": "float",
      "stored_type": "float",
      "source": "user",
      "confidence": null,
      "rationale": null,
      "updated": "2026-08-26 16:37:29 UTC"
    },
    {
      "field": "vendor_name",
      "value": "Acme Supply",
      "declared_type": "string",
      "stored_type": "string",
      "source": "user",
      "confidence": null,
      "rationale": null,
      "updated": "2026-08-26 16:37:29 UTC"
    },
    {
      "field": "author",
      "value": "Jane Doe",
      "declared_type": "string",
      "stored_type": "string",
      "source": "ai",
      "confidence": "high",
      "rationale": "Identified from the document byline",
      "updated": "2026-08-26 09:03:44 UTC"
    }
  ]
}

The body is the node’s COMPLETE set of facts after the write, not an echo of what you sent — fields you did not name come back untouched, with whatever provenance they already had. Every field in each item means exactly what it means on the GET above.

A value this endpoint wrote comes back with source: "user" and confidence: null. The null is not missing data: a value a person typed has no extraction confidence to report, and reporting one would invent a measurement nobody took. rationale is null for the same reason.

🔴 category and sub_category hold only their published values — an off-list value is SILENTLY REPLACED with Other, not rejected. These two fields are the only ones whose VALUES come from a fixed list; every other field is open vocabulary. Sending "category": "Financial" succeeds with 200, and what is stored is Other — the same normalisation AI extraction applies to an answer it cannot place. There is no error, no warning field and no rejected entry.

So the value you sent is not necessarily the value stored, and nothing in the response status tells you that. The response echoes the STORED state, so read the classification back from it rather than assuming your input survived. This is the one place on this endpoint where a 200 does not mean “what you sent is what is there” — every other refusal on this route fails loudly instead.

A different SPELLING is not a different value. "finance", "FINANCE" and " Finance " are all accepted and stored as Finance, the published spelling, which is the same normalisation extraction applies. That is what lets you filter on Finance without also checking for a finance variant. The response echoes the stored state, so the canonical spelling comes straight back to you.

A null classification is not off-list. null asserts nothing and is dropped before this check runs, so a client echoing a whole form back with its untouched cells left null is never told its category is invalid.

🔴 One consequence worth designing for, and it is SILENT. A few files hold a classification that predates the list — extracted before it was closed, migrated from the template system, or inherited by a copy (see above, under Two reserved fields have a CLOSED value list, for the exempt sets). Re-sending such a file’s STORED category back through this endpoint succeeds with 200 and quietly replaces that historical value with Other, because the value is not on the list. Nothing in the status code reports it; the response is the stored state read back, which is the only place it shows. Send only the fields you are actually changing, or null for untouched cells — not because the request would fail, but because it would quietly succeed at something you did not ask for.

Refusals name the field. A refused request carries the offending names in the error message AND repeats them as structured error.params entries — a list of {name, kind, message, code, reason}, one per field, where name is the FIELD name and kind is invalid. Branch on reason, never on the message text:

reasonMeaning
field_name_blankThe field name is empty or only whitespace
field_name_too_longThe field name is longer than 64 characters
field_name_unsafeThat name cannot be used as a field name
field_name_not_canonicalThe field name has leading or trailing whitespace. Send it without the padding — it is refused rather than trimmed for you
value_type_mismatchThe value cannot be stored as the field’s declared type — e.g. text sent to an int field. message says what the value would have had to be
duplicate_fieldTwo names in this payload resolve to the SAME field, which happens when one has been merged into the other. Both names are listed, each naming the other; send one of them

Errors

HTTPCodeReason
4061605The body is not a JSON object, facts is missing, facts is not a JSON object, facts is empty, or it carries more than 100 entries. Nothing was written
4061605At least one entry could not be stored — an unusable name, a value that will not fit its field’s declared type, two names resolving to one field. error.params[] names every one. No value was written; see A refused write changes nothing above
4041609The node does not exist in this workspace. A node in another workspace answers identically
4061605The node exists but is not a file or note (e.g. a folder). No value was written
4121685This workspace’s field vocabulary is at its plan limit. Merge or remove a field, or upgrade the plan, then retry. No value was written; see A refused write changes nothing above
5031693This workspace’s metadata is momentarily busy with another metadata operation. No value was written — back off briefly and resend the request unchanged; see A refused write changes nothing above
5001664The facts could not be written, or could not be read back afterwards

503 is the answer to contend with, not to treat as a failure: metadata writes on one workspace take their turn against field merges and extraction runs, and the request that loses that race has stored nothing. Resending it unchanged is correct.

Every error above means no value was written EXCEPT a failure to read the facts back. The values are committed before the response body is assembled, so a request that stores its facts and then cannot produce the confirming read fails rather than answering 200 with an empty set — which would be indistinguishable from a node holding no metadata. If a write errors on the read side, re-read the node with the GET on this path to see what landed.

🔴 That exception has TWO forms, and one of them shares a status code with an ordinary refusal. A read-back failure that may clear on its own answers 500 (1664). One that will not answers 406 (1605) — the same pair every validation refusal uses — so the status alone cannot tell you whether your write landed. The discriminator is error.params: every validation refusal carries it, naming each offending field, because the request never got as far as writing. A read-back failure carries no error.params at all, because no field was at fault. So a 406 with error.params means nothing was written and the named fields need fixing; a 406 without it means the write may have landed and could not be confirmed — do not retry it, re-read the node with the GET on this path.

Resending the same request is always safe. Writing a value you have already asserted produces exactly the same stored state — a second identical write does not stack, duplicate, or change anything, down to the updated timestamp. That is what makes retrying the right response both to a 503 and to the one error that can leave the values committed.

Update file metadata (RETIRED — 410 Gone)

POST /current/workspace/{workspace_id}/storage/{node_id}/metadata/update/

Retired — this path answers 410 Gone.

🔴 RETIRED. This path answers 410 Gone for every request that reaches it, and it is never coming back. (A request the shared header rejects first — a malformed x-ve-idempotency, an invalid ?output= — still answers 406 here as it would on any endpoint. If you are probing whether this route is retired, send a well-formed request, or you will read a header refusal as a live route.) Write node metadata at POST /current/workspace/{workspace_id}/storage/{node_id}/metadata/facts/ instead — see Write node metadata facts above.

The response body names the replacement:

{
  "result": false,
  "error": {
    "code": 191138,
    "text": "This endpoint has been retired. Do not retry it; the path will not return. Write node metadata by POSTing to .../storage/{node_id}/metadata/facts/ with a JSON body of the form {\"facts\":{\"<field name>\":<value>}}; the key_values form this route accepted is not read there.",
    "resource": "POST Workspace [param] Storage [param] Metadata Update"
  }
}

It is retired, not deprecated-but-working. Nothing you send is read — not key_values, not template_id, not a trailing {template_id} path segment, not the method. Every request shape gets the same 410. Do not retry it, do not vary the payload, and do not report it as an outage: a 410 is the path telling you, on its own, that it is finished, which is exactly what a 404 cannot do. If a client library still offers a “set file metadata” call, check which path it sends before treating a failure as a service problem — one built against this route fails on every call until it is updated.

Three behaviours went with it, and code written against them needs changing, not just repointing:

The stored-state echo this route returned — template_metadata and custom_metadata — is gone with it. Confirm a write from what metadata/facts/ returns, which is the same read its GET would give you.

Delete file metadata

DELETE /current/workspace/{workspace_id}/storage/{node_id}/metadata/

Delete metadata keys from a file.

Auth: Bearer token required. Workspace member. Metadata billing feature required.

ParameterTypeRequiredDescription
keysstring (JSON)NoJSON-encoded array of key names to delete. Omit the parameter entirely, or send the empty array [], to delete every value on the file. Sending it blank (?keys=) is a client error, not a shorthand for “everything”.

keys must be a JSON array. A JSON object — including the empty object {} — and a bare scalar are refused as a client error, before anything is deleted. That distinction is load-bearing rather than pedantic: {} used to read as “no keys named” and deleted every value on the file, and {"title":"summary"} used to read as the one-element list ["summary"] and deleted the field named summary — a field the request never mentioned. Only an omitted keys, or the empty array [], means delete-everything. A keys that is PRESENT but blank is refused for the same reason: it is a request that did not say what it meant, and “everything” is the most destructive way to guess.

Naming a key the file does not carry is not an error; deleting an absent key succeeds, as a delete should. A named key is removed completely: a field name can stand behind more than one stored value (one per template that declares it, plus an untemplated one), and all of them go.

“Completely” covers both kinds of value a file can carry — the ones you set yourself and the ones extraction produced — so a deleted key stops appearing in the file’s metadata, in the field and value listings, and in metadata search.

A 200 means the removal finished. A failure does NOT mean it never started. A delete removes what you named in more than one step, and there is no all-or-nothing guarantee across them. If the request fails partway with 1664 (Datastore Error), part of what you named may already be gone while the rest is still stored — and the remainder stays visible, so reading the file’s metadata back afterwards can show a partially deleted set. Do not read a failure as “the delete was rejected, nothing changed”.

Retry the same request. Deleting is idempotent — naming a key that is already gone succeeds — so re-sending the identical call is the right response to a failure and finishes the removal. Confirm by reading the file’s metadata back, not from the status code alone.

A delete also has to take its turn against extraction and field merges running in the same workspace, so a request can be answered as temporarily unavailable when that workspace is busy. That case is different: nothing at all was removed — the work never began. Retry the same request.

Only files and notes support metadata — folders return an error.

Extract metadata (single file)

POST /current/workspace/{workspace_id}/storage/{node_id}/metadata/extract/

Enqueues an AI extraction job for a single file. Asynchronous — returns HTTP 202 Accepted with a job descriptor. Poll GET /current/workspace/{workspace_id}/jobs/status/ to track progress, then read the values from GET /current/workspace/{workspace_id}/storage/{node_id}/metadata/details/ once the job reports completed. Supports documents, spreadsheets, images (PNG, JPEG, WebP), and code files. Extracted values join the file’s metadata_facts corpus and are stored with source: "ai".

Fields you have edited by hand are skipped, not overwritten. A hand-written fact carries source: "user", which outranks ai, so extraction leaves that field exactly as it is and creates no second copy beside it. The job still reports success. Only one thing hands such a field back: deleting it (DELETE .../storage/{node_id}/metadata/ with keys: ["field_name"]), which leaves no row for the field on that node. No write reopens it — a null sent to metadata/facts/ does nothing at all, and "", 0 and false are answers a person chose, so they store at source: "user" and keep the field protected. Deleting alone does not queue a re-run either: a full re-extract of a file version already extracted by the current extraction version answers already_extracted, so name the field in fields below to have it filled again.

Auth: Bearer token required. Workspace member. Metadata billing feature required.

ParameterTypeRequiredDescription
template_idRetired. Sending it is an error. A request that includes a non-empty template_id is rejected with 406 and error code 179390; omit the parameter. It is refused rather than ignored on purpose: extraction no longer has any notion of a template, so honouring it is impossible, and silently accepting it would run a full, billed extraction while you believed you had narrowed it. Use fields to name the metadata fields you want. It is refused wherever it arrives — body or query string.
fieldsarray of strings (JSON)NoRead from the POST body or the query string (the body wins when both are present); either way the value is JSON — ?fields=["amount","invoice_date"]. JSON-encoded array of field names scoping the extraction (see below — the scope is exclusive, so anything else the model returns is discarded). Must be a JSON array of non-null strings; a bare scalar, a null, an object, or a null member is rejected. A present-but-empty array is rejected too — omit fields to extract everything. At most 50 distinct names per request; duplicates are collapsed before that limit applies. Every name must already be in this workspace’s vocabulary — either declared with POST /current/workspace/{workspace_id}/metadata/fields/ or produced by an earlier extraction — read the current list from GET /current/workspace/{workspace_id}/metadata/fields/. An unrecognised name is rejected, not ignored, so a 202 always means the scope you sent is the scope that was accepted. Omit fields to extract everything available.

If you are still sending template_id, stop — that request now fails. The response is HTTP 406, carrying the standard error envelope with:

{
  "code": 179390,
  "text": "The `template_id` parameter is retired and no longer selects what this extraction reads: metadata is no longer associated with templates. Remove the parameter to extract everything available, or use `fields` to name the metadata fields you want."
}

Removing the parameter is the whole fix; nothing else about the call changes. A caller that never sent it is unaffected.

There is one extraction path. Every extraction reads the workspace’s own field vocabulary and writes the same kind of result. template_id is always null in the response — it is kept in the payload so a client that reads the field keeps receiving it, so treat it as nullable and do not branch on it. fields echoes the scope you asked for, or null when you asked for none.

What fields does, precisely.

Omitting fields is a discovery pass: the fields this workspace already uses are offered to the model as context, and it may add new ones or correct existing ones. An unscoped request for a file already extracted at its current version by the current extraction version answers already_extracted and does no work. When Fastio’s extraction engine is upgraded, a plain re-extract on a file processed by an earlier version runs again and is billed as a new extraction — that is how existing files pick up newly supported fields.

Naming fields makes the request EXCLUSIVE. The model is asked for exactly those fields and nothing else, and any other field it returns anyway is discarded rather than written — so a targeted request cannot quietly rewrite columns you did not ask about. It also makes the request distinct from the full extraction, so a file that was already extracted runs again for the fields you name; that is how a field added to your vocabulary later reaches files that predate it. The file is still read in full, and a named field is only written if the document actually contains it — the model omits what it cannot find rather than guessing. Requesting the same fields twice does not run twice: the second request matches the first one's record and does no work.

Re-requesting extraction for a file version that has already been processed by the current extraction version returns HTTP 200 with "status": "already_extracted" and a null job_id. It does not queue duplicate work and is not billed again. Once Fastio’s extraction engine is upgraded, a plain re-extract on a file processed by an earlier version returns 202 instead: it runs again and is billed as a new extraction, which is how existing files pick up newly supported fields.

That 200 is only returned for an UNSCOPED request. A request naming fields is a different unit of work, so it is not answered from the full extraction's record — it returns 202 as normal. If that exact scope has already been run, the duplicate is detected later, by the worker, which does no work and does not bill; you will simply see the job finish without new facts. So do not treat 202 as proof that work was performed, and do not treat the absence of a 200 as proof that it was not.

Response (HTTP 202 Accepted):

{
  "result": true,
  "job_id": "{job_id}",
  "template_id": null,
  "node_id": "{node_id}",
  "fields": ["amount", "due_date"],
  "status": "queued",
  "status_uri": "https://api.fast.io/current/workspace/{workspace_id}/jobs/status/"
}

🔴 node_id comes back UNHYPHENATED. String equality against the id you put in the URL is FALSE. A node opaque id is written with hyphens in a path, and this response reports the same id with the hyphens stripped:

you called       POST .../storage/26t7z-x432g-nzlqq-pgqz7-r5i2h-lawn/metadata/extract/
the 202 answers  "node_id": "26t7zx432gnzlqqpgqz7r5i2hlawn"
the status entry "node_id": "26t7zx432gnzlqqpgqz7r5i2hlawn"

A client that compares the jobs-status entry against the hyphenated id it sent matches nothing, and it does so silently — no error is raised, the metadata_extract array simply never appears to contain your file, and the poll runs until you give up. Correlate using the node_id from this response body, not the one you wrote into the path: the 202, the already_extracted 200 and the jobs-status entry all report the same unhyphenated form, so they agree with each other and only the URL form differs. If you must compare against your own copy, strip the hyphens from both sides first.

status_uri is an absolute URL, and you can follow it verbatim. It used to be emitted as a bare path with no version segment and no trailing slash, which did not route — a client following it as given got a “resource not found” platform error. It is now a full URL whose host is the API host for the environment the call was made against, so treat any relative form you have hardcoded as stale.

status is queued on a 202, or already_extracted on a 200. This route emits no other status — in particular it never answers in_progress.

Repeat requests are safe and are not double-billed, and there is no time limit on that. The protection is keyed on the file’s current version, the set of fields you named, and the extraction version that processed it — not on a window — so a retry an hour later is exactly as safe as one a second later, while uploading a new version of the file, or an upgrade to Fastio’s extraction engine, makes the next request a genuinely new unit of work. Field names are compared as a set: order does not matter and duplicates are collapsed, so ["amount","due_date"] and ["due_date","amount","amount"] are the same scope.

A duplicate request always receives a new job_id. This route never hands back the job_id of a job already in flight, so two 202s carrying different job_ids do not mean two extractions were performed.

Polling flow. Every extraction returns a status_uri.

POST /current/workspace/{id}/storage/{node}/metadata/extract/
  -> HTTP 202
     { "job_id": "{job_id}", "status": "queued",
       "status_uri": "https://api.fast.io/current/workspace/{id}/jobs/status/" }

GET  /current/workspace/{id}/jobs/status/
  -> jobs.metadata_extract[] includes
     { "kind": "single",
       "active": true,
       "node_id": "{node_id}",
       "template_id": null,
       "job_id": null,
       "status": "queued",
       "progress_percent": 0 }

GET  /current/workspace/{id}/jobs/status/     (later)
  -> entry now { "status": "completed",
                 "progress_percent": 100,
                 "completed_at": ... }

GET  /current/workspace/{id}/storage/{node}/metadata/details/
  -> extracted values under `metadata_facts`

🔴 An extraction started by this route goes queuedcompleted (or errored). It never reports in_progress. There is no intermediate republish on this path: the entry is seeded queued when the request is accepted and rewritten once, at the outcome. A client that waits to see in_progress before it starts watching for the result waits forever. (in_progress is a real value on this surface, but only on the per-file entries of a folder-level extract-all that ran against a template — see the status field description under Job Status below. It is a property of which route started the work, not of kind, and it always arrives with a non-null template_id.)

An entry can return to queued after having been picked up: a single-file extraction that is interrupted by a transient condition is re-queued rather than failed, and re-reports queued until it runs again. For an extraction started on this route, treat completed and errored as the only stopping points — polling until the entry simply stops being active is equivalent, but polling until status changes at all is not.

That stopping rule is specific to THIS route. It holds because a transient failure here republishes queued rather than errored, which makes errored genuinely final. The templated folder-level arm does the opposite — it publishes errored on a retryable failure and can resume afterwards — so do not carry this rule across to an entry you did not start here. See the status field description under Job Status below.

job_id is null while the extraction is in flight, and carries the 202’s exact id once the entry is terminal. The status snapshot is seeded before the job row exists, so a kind: "single" entry started by this route reports job_id: null for as long as it is queued; at completed or errored the entry reports the same job_id the 202 gave you, byte for byte. So it is usable to correlate — it is in fact the only key that identifies your request, where node_id identifies only the file — but a match on it succeeds only from the terminal entry onward. Choose accordingly: node_id if you need to see the entry while it is still running, job_id if you need to be sure the terminal entry is the one you started. (Nothing republishes the entry between the seed and the outcome, so no intermediate job_id value exists to observe.)

And never on template_id. The status array is workspace-wide, and template_id is filled in by the server, so you have nothing of your own to compare it against: this endpoint always reports null there, while a folder-level run happening at the same time contributes entries naming its own template. Matching on it therefore selects unrelated entries or none, depending on what else the workspace is doing.

Error responses:

Error CodeHTTP StatusCause
1605 (Invalid Input)406Node is root, fields references an unknown field, or payload is malformed
1605 (Invalid Input)406The file's format is excluded from metadata extraction (SVG) — deterministic, retrying cannot succeed
179390406template_id was sent — the parameter is retired and is refused, not ignored
1609 (Not Found)404Node not found
1664 (Datastore Error)500Failed to enqueue extraction job
1696 (Credits Exhausted)402No AI credits remaining

Batch extract metadata for a folder

POST /current/workspace/{workspace_id}/storage/{node_id}/metadata/extract-all/

Enqueue an async job that runs metadata extraction on every file in a folder. This is the folder-level counterpart to the single-file /metadata/extract/.

A template is not required. If the workspace has an active template the run extracts against it and establishes each file’s assignment as it goes, as described below. If the workspace has no active template, the run extracts each file for whatever metadata it supports instead — this previously answered 404 "No template configured for this workspace", so a workspace that had never configured a template could not run folder-wide extraction at all. On that path template_id in the response is null, and none of the template-assignment or per-template cap behaviour below applies.

Auth: Bearer token required. Workspace admin permission. Metadata billing feature required. Conservative throttle (these operations are expensive).

{node_id} is a folder node opaque id, or the literal root alias for the workspace root.

Parameters:

ParameterRequiredDescription
fieldsoptionalJSON array of field names to restrict this run to, e.g. ["Invoice Total","Vendor"]. Read from the POST body or the query string (the body wins when both are present); either way the value is JSON — ?fields=["Invoice Total","Vendor"]. Omit to extract everything available.

Scoping a run to named fields. fields answers “I just added a field — fill it in for this folder” without re-extracting everything. It is the folder-level counterpart to the same parameter on the single-file /metadata/extract/.

Response (200 OK):

{
  "result": true,
  "job_id": "aj_aBcDeFgHiJkLmN",
  "template_id": "mt_oPqRsTuVwXyZ12",
  "fields": null
}

fields echoes back the scope the run was narrowed to, or null for an unscoped run. A scoped request that comes back with fields: null means the scope did not take effect.

The job runs asynchronously. On an unscoped call it runs against the workspace’s active template when there is one; a fields-scoped call is always template-free (see above), so nothing below about templates applies to it. Poll GET /current/workspace/{workspace_id}/jobs/status/ (the jobs.metadata_extract[] array) for progress.

The run establishes each file’s template assignment as it goes, and the per-template file cap bounds those assignments. A file the run reaches that is not yet mapped to the template is mapped before it is extracted, and that mapping is counted against the plan’s per-template file cap like any other. When the cap is full, a file that would need a new mapping is skipped and the walk continues: the cap bounds mappings, not extraction, so a file already mapped to the template needs no slot and is still extracted wherever it appears in the walk. A run that skipped at least one file for this reason reports stop_reason: "template_node_cap" in both the progress snapshot and job status. Files already extracted in that run keep their values. Raise the cap (or unmap files you no longer need) and re-run to pick up the skipped ones.

Error responses:

Error CodeHTTP StatusCause
1605 (Invalid Input)406Missing/invalid folder id, the node is not a folder, fields is present but empty, fields names a field this workspace has never produced, or fields exceeds 50 entries
1609 (Not Found)404Folder not found, or no template is configured for the workspace
1664 (Datastore Error)500Failed to list templates, resolve a requested field, or enqueue the job

Metadata versions

GET /current/workspace/{workspace_id}/storage/{node_id}/metadata/versions/

List metadata version snapshots for a file. These snapshots are historical only. Each entry replays a stored snapshot in the older key/value shape (key, value, value_type, is_auto) — the shape no other endpoint serves any more — and no current write records a new one, so this reports what was captured under the previous model rather than a running history of today’s fact writes. To see what a file holds now, read metadata/details/ or metadata/facts/.

Saved metadata filters

The per-user saved-view endpoints (metadata/view/, metadata/views/) have been removed and replaced by workspace-shared saved filters: a named predicate over extracted metadata plus an optional display projection. An empty predicate is valid and means “everything” — the unfiltered wide view you narrow down from. It is accepted on create and update, and executing it returns the workspace’s nodes that carry any extracted metadata, under the same result cap, scope_truncated flag and plan node cap as any other filter execution. A malformed predicate — a clause that is not an object, or one missing its field or operator — is still rejected with 1605 (Invalid Input).

The projection is an optional ordered field list plus an optional sort spec, stored and returned exactly as sent. sort is the only key we interpret — a spec of the form {"sort": {"field": "updated", "dir": "desc"}} naming a sortable FILE column (name, size, updated) becomes the execute endpoint’s default sort, and an explicit sort_field parameter always overrides it. The stored hint names file columns only — it is never resolved against your metadata vocabulary, so to order by a metadata value pass sort_metadata_field on the request (below). Everything else in the projection is opaque: never validated, never rewritten. You choose the field-list shape, so do not assume a filter you did not create uses yours — filters produced by the saved-view and template migrations carry a columns list, because they hold per-column display state a flat list cannot express. Read defensively: look for sort if you care about ordering, and treat the rest as data you may not have written.

Ordering the execution (.../{filter_id}/nodes/) uses one of two axes, and they are separate parameters. sort_field orders by a file column (name, size, updated). sort_metadata_field orders by the value of one of your metadata fields, named exactly as your workspace vocabulary holds it. sort_dir (asc or desc) applies to whichever axis you used, and defaults to desc for updated and asc for everything else, including every metadata field. They are two parameters because a workspace is free to declare a field called name or size, and one parameter could not tell that field from the built-in column — you would get the wrong order with nothing in the response to show it. Sending both is 1605 (Invalid Input): one list cannot have two orders.

sort_metadata_field must name a field that exists in this workspace and whose type can be ordered. An unknown name, or a json field (a JSON document has no ordering — what would be compared is its encoding), is 1605 (Invalid Input) rather than being quietly ignored. Two guarantees worth relying on: files with no value for that field sort LAST in both directions, so flipping sort_dir changes the order of your results and never which ones appear first; and ties are broken consistently, so two identical requests return the same list in the same order.

When the match set exceeds the result cap, the axis you sorted on decides what is kept. With sort_metadata_field the cap is applied in that order, so a capped page really is the top N by that value. With sort_field the cap is applied first and the surviving nodes are then ordered, so a capped page is a sample re-sorted — check scope.scope_truncated before reading it as a top-N.

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 required. Workspace member. Metadata billing feature required.

Any workspace member may create, edit and delete filters. Filters are shared, and there is no per-filter ownership — a member can edit or delete a filter another member created. If that matters in your interface, guard it yourself.

Request encoding

MethodWhere parameters go
POST create, PUT updatea JSON object request body (application/json), read whole
GET list, GET executequery-string parameters
GET one, DELETEno parameters

A POST or PUT body that is not a JSON object is 1605 (Invalid Input), HTTP 406. These two routes do not read form-encoded fields — the predicate and projection are structured JSON, so the whole body is parsed as one object.

Create (POST) and update (PUT) body

NameTypeRequiredNotes
namestringyesTrimmed; must be non-empty; max 100 characters. Unique per workspace — a collision is HTTP 409
predicatearrayyesThe clause list described below. Send [] explicitly for match-all; omitting the key is an error, not a default
descriptionstringnoMax 255 characters
projectionobject or arraynoStored as sent; only sort is interpreted
template_idstringnoCreate only — records which template this filter succeeds. Ignored on update

Length and clause-cap violations (name over 100, description over 255, predicate over 5 clauses) all return the same 1605 (Invalid Input), HTTP 406, with one generic message — the response does not say which of the three you hit, so validate them before sending if you need to tell a user which one to fix. Names are unique per workspace, and the field vocabulary a predicate references is per workspace — the same field name in another workspace is unrelated.

PUT REPLACES the filter — it is not a partial patch. Every field is written from the request body on every call:

So to change one field, read the filter first, then send the whole object back with your edit applied. Sending only {"name": "..."} is rejected (no predicate); sending {"name": "...", "predicate": [...]} succeeds and wipes the description and projection.

template_id is accepted only on create. On update it is ignored — a filter's provenance and creation time are carried over from the stored record and cannot be rewritten by a caller, and an update can never move a filter to another workspace.

Responses

OperationBody
create · get one · update{"result": true, "filter": { … }}
list{"result": true, "count": 2, "items": [ … ], "cursor": null, "has_more": false}
delete{"result": true} — no payload beyond it
execute{"result": true, "items": [ … ], "scope": { … }}

Every response carries the platform's top-level "result": true alongside the payload, exactly as elsewhere in this API. Note the list keys are flatcount, items, cursor and has_more sit beside result, not nested under a wrapper — and the collection key is items, not filters.

A filter object carries id, name, description, predicate, projection, template_id, created and updated. Timestamps use Y-m-d H:i:s UTC (for example 2026-04-27 16:37:29 UTC). description, projection and template_id are null when unset.

output=terse returns id, name and description only — the predicate, projection, template_id and timestamps are absent, not null. Supplying no output gives you the default, full, which carries them; standard carries them too. Only terse drops them, so send terse only where you genuinely need nothing but the filter's identity.

Listing

Cursor-paginated. page_size accepts 1–250 and defaults to 100; a value above 250 is silently reduced to 250, while 0 or a negative value is rejected. Pass the cursor from the previous response to continue.

Page with has_more and cursor, never with countcount is the number of items in the page you are holding, so a short page is not a signal that the listing is finished.

Deleting

DELETE is idempotent and deliberately uninformative: an id that never existed, one already deleted, and one belonging to a workspace you cannot see all return the same 200 {"result": true}. This is so the response cannot be used to discover which filter ids exist elsewhere. A 200 therefore does not prove anything was deleted — do not report “deleted” as a confirmed outcome on the strength of it. There is no confirmation parameter. A 503 means the delete may or may not have happened and is safe to retry.

Reading a filter behaves the same way: a nonexistent, deleted, or foreign filter_id is one indistinguishable 404.

Limits

Each plan caps how many saved filters a workspace may hold. Exceeding it is a denial, not a validation error and not retryable, and the response body names the limit — so surface it as “no filter slots left” rather than “invalid filter”.

It answers HTTP 401, which does not mean your credentials are wrong. This platform maps denials onto 401, so re-authenticating or refreshing a token will not help and the request will keep failing. Read the returned error code and message rather than the status alone before deciding a 401 is an auth problem.

Plans also cap how many nodes one execution returns; see scope below.

The predicate is a JSON array of {field, operator, value} clauses, AND-chained, at most 5 per filter. Operators: = != < <= > >= (value required), in (non-empty list), exists / not_exists (no value), and confidence_gte (int 0-3, the stored value's confidence band: 0 = low, 1 = medium, 2 = high, 3 = certain).

Two traps in confidence_gte. confidence_gte: 3 matches deterministic sources only (exif, mediainfo, validated_server) — AI-extracted values are capped at high on write, so 3 excludes them rather than selecting the best of them. And confidence_gte: 0 is not “no minimum”: a value entered by a person has no extraction confidence, and a null confidence satisfies no level including 0, so 0 drops every hand-entered value. Use exists to match a field however its value was obtained.

Send the integer — a decimal string such as "2" is accepted, but a band name such as "high" is rejected, as is any non-integer including 2.0. A value the server cannot use is refused when the filter runs, not when it is saved: a filter carrying one is created successfully and then fails with HTTP 406 every time its nodes are listed.

A value may be a bare JSON integer and is compared exactly as sent. Do not round-trip one through a JavaScript Number — anything above Number.MAX_SAFE_INTEGER (9007199254740991) silently loses precision if you JSON.parse it and re-serialize. Pass it through unmodified.

Create and update validate STRUCTURE only — that each clause names a field and an operator, within the clause cap. Whether the field exists, whether the operator is legal for that field's declared type, and whether the value renders are all checked when the filter is EXECUTED. A filter can therefore be created successfully and still return 406 when its nodes are listed.

Execute (.../{filter_id}/nodes/) returns the matching storage nodes plus a scope object naming every way the answer was bounded, so a truncated result is never silently presented as complete. See the Storage Operations docs for the endpoint summary.

Jobs Status (Unified Async Processing)

A single endpoint to check the status of all async processing jobs (AI indexing, metadata extraction) for a workspace or share. Replaces the removed metadata/intelligence/status and metadata/templates/{id}/extract-status endpoints.

GET /current/workspace/{workspace_id}/jobs/status/

Workspace jobs status. Auth: Workspace member. Feature gate: AI feature must be enabled on the organization plan.

Returns every async job family running in the workspace, so one poll answers “is anything happening here”: intelligence, metadata_extract, template_match, upsert_file, summarize, and import_sync.

import_sync is a list (a workspace can sync several cloud sources at once), newest first, each entry carrying active, status (pending · running · completed · failed · canceled · stalled), job_id, import_source_id, job_type, files_added, files_updated, files_deleted, bytes_transferred, started_at, completed_at, duration_seconds and error_message. Finished entries drop off after an hour; an active entry whose worker stopped reporting is demoted to stalled rather than spinning forever.

Cloud-sync discovery jobs are deliberately absent — browsing your own cloud account is not workspace activity, and those results stay owner-only. Use GET /current/cloudsync/details/discovery/jobs/{job_id}/ for those, and GET /current/cloudsync/details/{source_id}/jobs/ for one source’s full history.

upsert_file reports the search indexing of the workspace’s files, and it is an OBJECT (or null when nothing has been indexed recently), not a list. It carries active (boolean), status (queued · processing · completed · failed), node_id and node_name (string or null), the file counters file_count, processed_count and failed_count (integers), current_file (string or null), current_file_units_indexed and current_file_units_total (integer or null), progress_percent (0–100, computed from the FILE counts), and started_at / updated_at / completed_at (unix seconds; completed_at is null while running).

The two current_file_units_* fields are WITHIN-FILE progress, measured in the units the indexer measured that file in — pages, for a document. A file large enough to need several indexing passes is one file, so these are the fields that move while processed_count and progress_percent stand still: a client showing progress through a large document polls them, not the file counters. Both are always present and both are null when the indexer reported no measurement and once the file is finished or failed. The snapshot is one entry per workspace or share, so while several large files are indexed at once the pair describes whichever one reported most recently.

{
  "active": true,
  "status": "processing",
  "node_id": "{node_id}",
  "node_name": "annual-report.pdf",
  "file_count": 0,
  "processed_count": 0,
  "failed_count": 0,
  "current_file": "{node_id}",
  "current_file_units_indexed": 1000,
  "current_file_units_total": 2500,
  "progress_percent": 0,
  "started_at": 1711500000,
  "updated_at": 1711500420,
  "completed_at": null
}
GET /current/share/{share_id}/jobs/status/

Share jobs status. Auth: Share viewer. Feature gate: AI feature must be enabled on the organization plan.

Response (200 OK):

{
  "result": true,
  "jobs": {
    "intelligence": {
      "active": true,
      "status": "ingesting",
      "direction": "enable",
      "total_files": 100,
      "eligible_files": 80,
      "processed": 30,
      "skipped": 5,
      "failed": 0,
      "progress_percent": 37,
      "started_at": 1711500000,
      "updated_at": 1711500300,
      "completed_at": null,
      "stop_reason": null
    },
    "metadata_extract": [
      {
        "kind": "batch",
        "active": true,
        "template_id": "1234567890123456789",
        "node_id": null,
        "job_id": "{batch_job_id}",
        "status": "extracting",
        "total_files": 50,
        "eligible_files": 40,
        "processed": 24,
        "skipped": 2,
        "failed": 0,
        "progress_percent": 60,
        "started_at": 1711500000,
        "updated_at": 1711500200,
        "completed_at": null,
        "stop_reason": null,
        "error_message": null,
        "fields_scope": ["name", "date", "amount"]
      },
      {
        "kind": "single",
        "active": true,
        "template_id": "1234567890123456789",
        "node_id": "{node_id}",
        "job_id": "{job_id}",
        "status": "in_progress",
        "total_files": 1,
        "eligible_files": 1,
        "processed": 0,
        "skipped": 0,
        "failed": 0,
        "progress_percent": 0,
        "started_at": 1711500400,
        "updated_at": 1711500400,
        "completed_at": null,
        "stop_reason": null,
        "error_message": null,
        "fields_scope": ["amount", "due_date"]
      }
    ]
  }
}

The second entry above belongs to a folder-level run, which is why it can show in_progress and a non-null job_id while still active — and why its template_id is NOT null. in_progress is written only on the templated arm: a folder-level extract-all in a workspace that still holds an active template and was run without a fields scope, or the template auto-match and schema-change surfaces. Every writer of that status carries the run’s template id into the same record, so 🔴 template_id: null together with status: "in_progress" is a combination this API does not produce — do not write a client branch for it. Note the direction of that rule: a fields-scoped extract-all is deliberately pushed off the templated arm and runs template-free, which is exactly why a scoped run reports template_id: null and never reaches in_progress. A non-null fields_scope beside in_progress is fine on its own, though — the templated arm carries a resolved scope.

An entry started by the single-file metadata/extract/ endpoint never looks like this while it is running — that route is template-free, so its entry reports status: "queued" with job_id: null and template_id: null until it goes terminal. kind does not tell the two apart; only knowing which call you made does.

FieldTypeDescription
jobs.intelligenceobject/nullAI indexing job status, or null if no job exists
jobs.intelligence.statusstringstarting, ingesting, flushing, draining, completed, failed, or stopped
jobs.intelligence.directionstringenable (indexing files) or disable (removing embeddings)
jobs.intelligence.progress_percentinteger0–100 progress based on processed/eligible
jobs.metadata_extractarrayMixed extraction statuses (empty array if none). Each entry is either a folder-level batch job or a per-node single-file job. A SCOPED (template-free) folder run appears here too, with template_id: null — it is not limited to runs driven by a template. stop_reason on a folder-level run says why the walk ended: completed; continued (the run hit its per-run file budget, checkpointed, and a SUCCESSOR job is already queued — more is coming, keep polling); credits_exhausted; entitlement_lost; or template_node_cap (at least one file was skipped because the per-template file cap left no room to map it). A run that FAILS reports the reason it failed instead — for example node_cap_unresolved, when the plan’s per-template file cap could not be read and the run refused to walk rather than run uncapped. Treat the value as an open set: match the values you handle and fall back for the rest, rather than switching exhaustively.
🔴 continued is the one that changes how you poll. A large folder is swept across SEVERAL jobs, each with its own job_id, so the id returned when you started the run covers only the first slice. Treating that first job reaching a terminal state as “the sweep finished” reports completion while values are still landing. continued is set only when the successor was actually enqueued, so it never promises a job that is not coming.
jobs.metadata_extract[].kindstring"batch" for a folder-level extraction reported as one unit, "single" for a per-file entry. kind tells you the SHAPE of the entry, not whether a template was involved — a folder-level run also publishes one kind: "single" entry per file it processes, so a workspace running a folder extraction shows one "batch" entry alongside many "single" ones that belong to it
jobs.metadata_extract[].template_idstring/nullThe template the extraction ran under, or null when none did. This is server-populated, so do not use it to find your job — see the correlation note below. It is null on every entry produced by the single-file extract endpoint, which is template-free. It is a real identifier on a folder-level run driven by one of the surviving templates, including on that run’s per-file kind: "single" entries — those inherit the run’s template, so "single" does not imply null here. When there is no template the value is nullnever the empty string, so one null test covers every absence
jobs.metadata_extract[].node_idstring/nullNode identifier for kind: "single" entries; null for kind: "batch". 🔴 Reported UNHYPHENATED26t7zx432gnzlqqpgqz7r5i2hlawn, not the 26t7z-x432g-nzlqq-pgqz7-r5i2h-lawn form you write into a URL path. It matches the node_id the extract endpoint returned in its own response body, so correlate against that; comparing against the hyphenated id you put in the path matches nothing, silently
jobs.metadata_extract[].job_idstring/nullAsync job identifier. On an entry started by the single-file extract endpoint it is null for as long as the entry is queued — the snapshot is seeded before the job row exists — and at completed or errored it carries the exact job_id that endpoint’s 202 returned. Nothing republishes the entry in between, so there is no intermediate value. It is the only key that identifies your request rather than the file, so it is worth matching on once the entry is terminal; use node_id when you need to find the entry while it is still running. Entries belonging to a folder-level run carry a job_id throughout, but it is each file’s own job id, not the one the folder request returned
jobs.metadata_extract[].statusstringBatch: queued, starting, walking, extracting, or completed states. Per-file entries use queued, in_progress, completed and errored — but which of them you can actually see depends on the route that started the work, not on kind. 🔴 An extraction started by the single-file metadata/extract/ endpoint goes queuedcompleted (or errored) and NEVER reports in_progress, because that path publishes no intermediate update; a client waiting to observe in_progress on it waits forever. in_progress does appear on the per-file entries of a folder-level extract-all that ran against a template — an unscoped run in a workspace that still holds an active one — which is why the value exists on this surface at all. A fields-scoped run does not qualify: naming a scope selects template-free extraction, and the template-free path never reports in_progress. So in_progress always arrives with a non-null template_id. A failed extraction reports errored, never failed. 🔴 Whether errored is TERMINAL depends on the route too, and the two arms behave OPPOSITELY on retry. On the single-file metadata/extract/ route it is terminal: a transient failure there republishes queued instead (so a queued entry may be a first attempt or a retry), and errored means nothing further will run. On the templated arm it is not terminal. A retryable failure there publishes errored with completed_at set — which looks exactly like a final failure — and the entry can then return to in_progress with completed_at cleared back to null and the same job_id, because the same job is re-dispatched. 🔴 Nothing in the payload distinguishes “errored, will retry” from “errored, final” — there is no retry count, attempt number or will-retry flag on the entry — so on a templated run do not tear down on the first errored; keep polling and watch whether it resumes.
jobs.metadata_extract[].error_messagestring/nullHuman-readable error message, or null. 🔴 Set on EVERY errored entry, not only a terminal one. The errored transition always writes the message, alongside completed_at, whether or not the job will be re-dispatched — so on the templated arm an entry that is merely waiting to run again carries a non-null error_message. ⇒ This field is not a way to tell “errored, will retry” from “errored, final” either, and neither is completed_at; nothing on the entry distinguishes them. It returns to null only when a later attempt publishes in_progress, or on completed.
jobs.metadata_extract[].fields_scopearray/nullThe field names being extracted, or null when the run is unscoped. null means “everything available”, not “unknown”. The scope is reported on the terminal entry as well as the pending one, so a finished run still tells you what it covered

Finding YOUR job in this array. metadata_extract is workspace-wide: it lists every extraction in flight, not only the one you started, so the first step of reading it is always picking your entry out. Match on an identifier you supplied or were handed, never on one the server fills in on your behalf — a server-populated field has no value on your side to compare against until the server has already told you what it chose.

Match onWhen it is usableNotes
node_idImmediately, for a single-file extractionThe extract call returns it in both its 202 and its already_extracted 200, and every per-file entry carries it from the moment the entry appears. This is the key to use while the extraction is still running. 🔴 Compare against the node_id from the response body, NOT the one you wrote into the URL path — both the response and the entry report it unhyphenated (26t7zx432gnzlqqpgqz7r5i2hlawn), the path form is hyphenated (26t7z-x432g-nzlqq-pgqz7-r5i2h-lawn), and a string comparison between the two is false with no error to tell you so. It also identifies the file, not your request: two extractions of the same file share one entry
job_idFor a single-file extraction, once its entry is terminalThe 202 gives you the real job id. The entry is seeded before the job row exists and nothing republishes it between queued and the outcome, so it reports job_id: null for as long as it is queued and then reports that exact id at completed or errored. This is the only field that identifies YOUR requestnode_id names the file — so it is the right key for confirming a terminal entry is the one you started; it just cannot find the entry earlier than that. Entries belonging to a folder-level run do carry a job_id throughout, but it is each file’s own job id, not the one the folder request returned
template_idNeverServer-populated. It is null for everything the single-file endpoint starts, so a client holding no template compares null against null and matches whichever unrelated entries happen to share it — or, in a workspace where a folder run is active, matches nothing at all because every entry names that run’s template

Both intelligence and extraction entries share the common progress fields (active, total_files, eligible_files, processed, skipped, failed, progress_percent, started_at, updated_at, completed_at, stop_reason). For kind: "single" entries, total_files and eligible_files are always 1 and progress_percent is 0 while pending or 100 on completion. Completed or errored entries older than one hour are hidden from the listing. completed_at is the signal to stop polling on the single-file metadata/extract/ route, where it is only ever set on a terminal entry and an entry waiting to run again carries completed_at: null with active: true. 🔴 On the templated arm it is a weaker signal: a retryable failure there stamps completed_at alongside errored, and a later retry clears it back to null — so a stamped completed_at on a templated entry does not prove the run is over.

🔴 started_at is REWRITTEN when the entry reaches a terminal state, and the original value is lost. The terminal record is written whole rather than patched onto the queued one, so it stamps all three timestamps with the same moment: on a terminal single-file entry started_at == updated_at == completed_at. Two consequences, both of which bite:

No key is ever absent from a metadata_extract entry — every difference between a pending snapshot and a terminal one is null-versus-value, never present-versus-missing. Each entry carries the same 18 keys (kind, active, template_id, node_id, status, total_files, eligible_files, processed, skipped, failed, progress_percent, started_at, updated_at, completed_at, stop_reason, fields_scope, job_id, error_message) whatever state it is in. So you can test for null and will never have to distinguish that from an undefined key. This is a statement about this payload only — do not generalise it to other responses.

The timestamps in this payload are integer Unix seconds, not the YYYY-MM-DD HH:MM:SS UTC strings most of the API returns. Parse them as integers. 🔴 import_sync is the exception inside this same payload — its started_at and completed_at are YYYY-MM-DD HH:MM:SS UTC strings, not integers, so a parser written against the other families breaks on that one entry. Branch on the family, not on the response. ⚠ And this payload is not the only exception — some billing and upload fields are integer timestamps too — so do not infer a format from a neighbouring endpoint; read each field's documented type.

Real-time updates: Both job types broadcast via the Activity/WebSocket system. Clients subscribed to the workspace or share WebSocket channel receive activity notifications when progress changes, reducing the need for polling.

Supported Field Types

TypeDescriptionStored as
stringText values (max 4,096 characters)string
intInteger numbers, signed 64-bitint
floatDecimal numbers. Whole numbers are exact to ±9007199254740992 (2^53); a larger integer is refused rather than rounded — send it to an int fieldfloat
boolBoolean true/falsebool
jsonJSON documents (max 4,096 characters)json
urlAbsolute http or https URLs (max 4,096 characters)string
datetimeDate and time values, normalized to UTC and returned as YYYY-MM-DD HH:MM:SS UTCdatetime

The “Type” column is what a fact reports as declared_type; the storage column the value actually lands in is reported as stored_type. For the exact stored_type spellings, read the item table under Get node metadata facts — notably a datetime field’s storage column is reported there as date.

The 4,096-character limit applies to every type stored as text (string, json, url) and counts characters, not bytes — a multi-byte character counts once. A longer value is rejected for that field rather than shortened.

What each declared type accepts:

Every stored datetime is converted to UTC and returned as YYYY-MM-DD HH:MM:SS UTC — the same date/time spelling every other field in this API uses, and the spelling this API also accepts back as a filter value. Do not parse for a T separator or a +00:00 offset; neither appears in a returned value.

A datetime is held as a real date/time value rather than as text, so <, <=, > and >= filters on one compare chronologically — the ordering does not depend on how the value was spelled when it was written. Two spellings of the same instant — 2026-01-02T10:00:00+00:00 and 2026-01-02T05:00:00-05:00 — are the same instant, so they store identically, match the same equality filter, and sort as one value.

Quick Reference

Create a chat and get the answer

POST /current/workspace/{id}/ai/agent/
  question=...
  -> thread.thread_id, turn.turn_id

GET  /current/activity/poll/{id}?wait=95&lastactivity=...
  -> wait for ai_chat:{chatId}

GET  /current/workspace/{id}/ai/agent/{chat_id}/message/{turn_id}/details/
  -> check status == complete

GET  /current/workspace/{id}/ai/agent/{chat_id}/message/{turn_id}/read/
  -> SSE stream: data, commentary, status, analysis_data, table_data, done

Create a note (bank knowledge for RAG)

POST /current/workspace/{id}/storage/{parent}/createnote/
  name=research-notes.md&content=...

Extract metadata from a file (async)

POST /current/workspace/{id}/storage/{node}/metadata/extract/
  -> HTTP 202 { job_id, status: "queued", status_uri }
  -> HTTP 200 { job_id: null, status: "already_extracted" }  (unscoped, already done)

GET  /current/workspace/{id}/jobs/status/
  -> jobs.metadata_extract[] (kind: "single") reaches status: "completed"

GET  /current/workspace/{id}/storage/{node}/metadata/details/
  -> extracted values

List eligible nodes for metadata (files and notes)

GET /current/workspace/{id}/metadata/eligible/

Check status of all async jobs (intelligence + metadata extraction)

GET /current/workspace/{id}/jobs/status/
GET /current/share/{id}/jobs/status/
  -> returns jobs.intelligence and jobs.metadata_extract[]

Semantic search (runs inside /storage/search)

GET /current/workspace/{id}/storage/search/?search=quarterly+revenue&limit=10
GET /current/workspace/{id}/storage/search/?search=quarterly+revenue&details=true

Optional details=true includes full node resource (previews, AI state, metadata, size) per result. Default limit drops to 10 when details enabled. Returns one result per file with its best-matching passage only — a document matching in several places still yields a single row.

Searching extracted metadata (a different route — /storage/search/ does not do this)

GET /current/workspace/{id}/metadata/search/?q=invoice&limit=25
GET /current/workspace/{id}/metadata/filters/
GET /current/workspace/{id}/metadata/filters/{filter_id}/nodes/
POST /current/workspace/{id}/metadata/compound-search/

/storage/search/ has never searched extracted metadata. Use /metadata/search/ for metadata values, or a saved metadata filter to retrieve by exact field value with no text query. Workspace-only — there is no share equivalent, and a share request that sends filters returns 200 with unfiltered results.

To answer a question that has a metadata half and a content half in one call — “the contracts signed last quarter that mention early termination” — use /metadata/compound-search/, which intersects a filters predicate with a semantic content_query and reports how the answer was bounded in a mandatory scope object. It needs Member, the metadata and content_ai plan features, and Intelligence enabled on the workspace. Full contract: Compound Search in the Storage reference.

Share AI chat (same workflow as workspace)

POST /current/share/{id}/ai/agent/
  question=...
  -> thread.thread_id, turn.turn_id

GET  /current/share/{id}/ai/agent/{chat_id}/message/{turn_id}/details/
  -> check status == complete

GET  /current/share/{id}/ai/agent/{chat_id}/message/{turn_id}/read/
  -> SSE stream: data, commentary, status, analysis_data, table_data, done

Share-specific AI

GET  /current/share/{id}/ai/autoog/
  -> binary PNG image (OG image)

POST /current/share/{id}/ai/autotitle/
  -> title, description, display_type

POST /current/share/{id}/ai/share/
  files=["opaqueId1","opaqueId2"]
  -> markdown with download URLs
↑ Back to top