---
title: "folder_metadata"
method: POST
path: "/v1/workspaces/{workspace_id}/tools/folder-metadata"
tags: ["tools"]
---

# folder_metadata

`POST /v1/workspaces/{workspace_id}/tools/folder-metadata`

## Request

- Base URL: `https://ndi-api.nace.ai` (the document declares no server; this is the origin it was published from)
- URL: `https://ndi-api.nace.ai/v1/workspaces/{workspace_id}/tools/folder-metadata`
- Auth: API key in header `X-API-Key`
- TypeScript: <https://skmtc.dev/nace/apis/ndi-service/docs/v1/workspaces/:workspace_id/tools/folder-metadata/post.ts> (a function with typed inputs; its response is checked against the schema)

List one folder of the workspace: file counts, categories, subfolders, and the files' exact paths.

**Cost class: fast.**

Explore in steps instead of listing everything at once:

1. Call with the defaults on ``/`` (the workspace root): the folder's own
   files plus one summary per subfolder (file count, top categories,
   failed-ingestion count).
2. Descend with ``directory=<folder>``, or narrow with ``name_contains=``
   (find a file by part of its name), ``category=``, ``ingestion_status=``
   (e.g. ``failed``) or ``kind=``. Counts and listing narrow together, and
   a narrow covers the whole subtree unless ``recursive=false`` is sent.
3. Pass a ``file_entries[].path`` to ``file-metadata`` to see what is inside
   that file, then ask with ``qa-file`` or ``query-tables``.

Every path is relative to the workspace root and never includes the
workspace id. ``name_contains`` finds files by what they are called — files
whose ingestion failed included; the search tools find them by what they
contain.

A ``directory`` that names a file is refused with ``invalid_request``
pointing at ``file-metadata``. An unfiltered call that matches nothing
answers with a ``hint`` naming the nearest existing folder and what it
holds, so a mistyped folder is not a dead end.

``file_entries`` comes one ``page_size`` page at a time, sorted by path;
``files_truncated`` reports a cut — resume with ``start_after=<last path>``
or narrow. Counts and listing only include files the caller's access
labels allow; ``coverage`` says how many were withheld.

Args:
    body: Folder, recursion flag, the narrows (category, ingestion_status,
        kind, name_contains), page size, cursor.
    workspace: Resolved workspace and caller access predicate.  A
        non-existent workspace returns ``workspace_not_found`` before the
        body executes.
    session: Database session.

## Path parameters

- `workspace_id` string, uuid, required: Workspace identifier.

## Headers

- `Idempotency-Key` string, nullable: Replaying a request with the same key returns the original job with 200, never a second job. The key is kept with the job for replay and never cleared, so it must not contain personal data.

## Request body

- FolderMetadataRequest: Request body for ``folder-metadata``: describe one folder of the workspace. Non-recursive by default: the first call on a folder orients (its own files plus a summary per subfolder), and the caller descends or narrows from there instead of receiving the whole subtree at once. A narrow (``category``, ``ingestion_status``, ``kind``, ``name_contains``) **implies recursion** unless ``recursive`` is sent explicitly: the counts those names come from cover subtrees, so the follow-up call must scope the same subtree. An explicit ``recursive=false`` alongside a narrow is honored.
  - `directory` string: Folder to describe, relative to the workspace root: `/` (the default) is the root, `evidence/2025` a folder under it. Never prefix it with the workspace id.
  - `recursive` boolean: Describe the folder's whole subtree instead of its own files only. A narrow implies true unless sent.
  - `category` string, nullable: Omit to include every file. Use a name exactly as a previous response's `categories[].name` printed it, e.g. `minutes/meeting_minutes`.
  - `ingestion_status` 'discovered' | 'queued' | 'ingesting' | 'ingested' | 'stale' | 'expired' | 'failed' | 'not_required': The only thing that says whether a file is searchable. ``expired`` cannot be derived from the hash pair: retention clears ``ingested_hash``, so an aged-out file and a never-ingested file look identical. They call for different customer actions, so the status carries the distinction the hashes cannot.
  - `kind` 'document' | 'table' | 'media' | 'json': The processing lane a file routes through — exactly one per file. ``DOCUMENT`` covers everything the categorize pipeline can split into logical documents (PDF, Office, Visio diagrams, images, text, markdown, HTML, email); ``TABLE`` covers spreadsheets and CSV; ``MEDIA`` covers audio and video, which become one speaker-attributed timeline plus sampled frames; ``JSON`` preserves arbitrary nested structure and may add a table projection for a top-level record array.
  - `name_contains` string, nullable: Only files whose path contains this text, case-insensitively — how to find a file you know by name. `%` and `_` match literally.
  - `page_size` integer: File entries per page. The counts always cover the whole scope.
  - `start_after` string, nullable: Resume the file listing strictly after this path: the last `file_entries` path of the previous page.

## Response `200`

Successful Response

- FolderMetadataResponse: Response body for ``folder-metadata``. Counts are filtered by both the caller's access predicate and the requested ``directory`` — the two compose as an intersection, so the view is neither wider than the access gate nor wider than the declared scope. ``file_entries`` lists the visible source files the counts cover (same scope and recursion as the counts), path-sorted, one page at a time (``page_size``); ``files_truncated`` reports a cut page — resume with ``start_after=<last path>``, or narrow. Source paths only, never ingestion derivatives.
  - `directory` string, required: The folder described, relative to the workspace root (`/` is the root).
  - `overall_files` integer, required: Files in scope that the caller can see: the folder, or its subtree when recursive.
  - `categories` CategoryCount[], required: File counts per category across the scope.
    - `name` string, required: Category path as assigned at ingestion, e.g. `contracts/agreement`.
    - `count` integer, required: Number of files in scope with this category.
  - `subdirectories` SubdirectorySummary[], required: The folder's immediate subfolders, each summarizing its subtree.
    - `name` string, required: The subfolder's name; append it to the described folder to descend.
    - `overall_files` integer, required: Files in the subfolder's whole subtree that the caller can see.
    - `top_categories` CategoryCount[]: The subtree's most common categories, most files first, capped.
      - `name` string, required: Category path as assigned at ingestion, e.g. `contracts/agreement`.
      - `count` integer, required: Number of files in scope with this category.
    - `failed_files` integer, nullable: Files in the subtree with ingestion_status=failed; absent when none.
  - `ingestion_summary` object, required: File counts per ingestion status across the scope.
  - `files_truncated` boolean, required: True when `file_entries` is one page of a longer listing; resume with `start_after`.
  - `file_entries` FolderFileEntry[], required: Files in scope, sorted by path, one page at a time.
    - `path` string, required: Workspace-relative path of the uploaded file — pass it to file-metadata, qa-file or query-tables as is.
    - `ingestion_status` 'discovered' | 'queued' | 'ingesting' | 'ingested' | 'stale' | 'expired' | 'failed' | 'not_required', required: The only thing that says whether a file is searchable. ``expired`` cannot be derived from the hash pair: retention clears ``ingested_hash``, so an aged-out file and a never-ingested file look identical. They call for different customer actions, so the status carries the distinction the hashes cannot.
    - `size_bytes` integer, required: Byte size of the uploaded file.
    - `category` string, nullable: Ledger document category, when classified.
    - `kind` string, nullable: Ingestion lane (document / table / media), when known.
    - `uploaded_at` string, date-time, nullable: When this file was first registered (ISO 8601).
  - `hint` string, nullable: Present only when a call matched nothing: unfiltered, the nearest existing folder and what it holds; filtered, how many files the folder holds and their categories.
  - `coverage` Coverage, required: What the access gate withheld, in counts and label names only. Never a name, path, or fragment of withheld content. Naming the labels leaks nothing the caller cannot already read from the workspace config, and it turns "some results were withheld" into an actionable request.
    - `restricted_candidates` integer
    - `labels_required` string[]: Which labels would have been needed to see the withheld candidates.
    - `request_access_hint` string, nullable
  - `job_id` string, uuid, nullable: The external operation job; absent for commands inside a search job.

## Other responses

- `400`: Bad Request
- `401`: Unauthorized
- `402`: Payment Required
- `403`: Forbidden
- `404`: Not Found
- `409`: Conflict
- `410`: Gone
- `413`: Request Entity Too Large
- `422`: Unprocessable Entity
- `424`: Failed Dependency
- `429`: Too Many Requests
- `500`: Internal Server Error
- `501`: Not Implemented
- `503`: Service Unavailable
- `504`: Gateway Timeout

---

[API](https://skmtc.dev/nace/apis/ndi-service.md) - [All operations](https://skmtc.dev/nace/apis/ndi-service/llms.txt) - [OpenAPI document](https://skmtc.dev/nace/apis/ndi-service/revisions/d9d1f8ec2207?raw)
