> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ndi.nace.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# File tools

> Explore, read, and question uploaded files — 5 endpoints

Five tools for working with workspace content. All of them address **the files
you uploaded** — never the internal representations ingestion produces. Splitting
a PDF into sections, extracting a spreadsheet's tabs into queryable tables,
transcribing a recording: all of that is workspace-internal machinery. You name
your own file; the platform resolves what to read or query under the hood. A
workspace-internal path in a request is refused with `invalid_request`, naming
the uploaded file to use instead.

The exploration loop is progressive — each step narrows the previous one
instead of dumping the corpus:

1. **`folder-metadata`** — *orient*: call it non-recursively (the default) on
   `/`; the per-subdirectory summaries say where to descend. Descend by
   `directory`, or narrow by `category` / `ingestion_status`.
2. **`file-metadata`** — *decide*: what is inside one file, how big is each part,
   which tool applies?
3. **`read-file`** / **`qa-file`** — *act*: read the text, or ask a question.
4. **`run-sql`** — run your own read-only SELECT over the workspace's tables
   (names from `file-metadata` / the folder census); **`query-tables`** — ask
   one natural-language question and let the engine plan, query, and answer.

A file's public **components** are the units `file-metadata` names: a PDF's
sections, a spreadsheet's tabs, a recording, an image, or the raw upload itself.
`read-file` and `qa-file` accept a `component` argument to scope a read or a
question to one of them.

`read-file` and `qa-file` require the file to be **ingested**
(`POST .../ingestions`); a file that is not returns `file_not_ingested`.
`folder-metadata` and `file-metadata` serve un-ingested files too, reporting
their `ingestion_status` so you can decide what to do next. For raw source
bytes, use `POST /v1/parse` or the artifact content route.

***

## Folder metadata

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

Orient in a directory: counts, category mix, per-subdirectory summaries, and
one page of file entries. The default call is **non-recursive** — it answers
with the directory's own files plus a summary of each immediate subdirectory's
whole subtree (file count, top categories, failed-ingestion count), which is
enough to pick a descent target without receiving the tree.

**Request body:**

```json theme={"dark"}
{
  "directory": "/"
}
```

**Body fields:**

* **`directory`** (optional, default `"/"`) — Folder path. Use `"/"` for the workspace root.
* **`recursive`** (optional, default `false`) — Count and list the whole subtree instead of the directory's direct children. Use only when you genuinely need the full census.
* **`category`** (optional) — Narrow both the counts and the listing to files whose ledger category exactly matches one of the reported names. This is the descent step for a flat directory that has no subdirectories to descend into.
* **`ingestion_status`** (optional) — Narrow both the counts and the listing to files in exactly this status (one of the `ingestion_summary` keys, e.g. `"failed"`). An unknown status is refused with a validation error.
* **`kind`** (optional) — Narrow to one ingestion lane: `"table"` (every spreadsheet/CSV in scope), `"document"` (paged and prose files), or `"media"` (recordings). An unknown lane is refused naming the valid ones.
* **`name_contains`** (optional) — Case-insensitive substring match against the uploaded path. Finds files by what they are **called** — the search tools find them by what they **contain**, and only a listing can find a file whose ingestion failed, since it was never indexed. `%` and `_` match literally.

All narrows compose (SQL intersection). A narrow **implies `recursive`**
unless you send `recursive` explicitly: the names those filters take come
from *subtree* censuses (root categories, subdirectory `top_categories` /
`failed_files`, `ingestion_summary`), so the follow-up call scopes the same
subtree. An explicit `recursive: false` alongside a narrow is honored as sent.

Two mistakes self-recover instead of dead-ending: a `directory` that names a
**file** is refused with `invalid_request` pointing at `file-metadata`, and an
unfiltered call that matches **nothing** (a typo'd path) answers 200 with a
`hint` naming the nearest existing ancestor and its subdirectories — e.g.
`"No files under 'planing'. '/' contains: execution/, planning/."` The hint
never names anything your access labels hide.

* **`page_size`** (optional, default 100, max 500) — File entries per page — your context budget knob.
* **`start_after`** (optional) — Resume the path-sorted listing strictly after this path (pass the last entry of the previous page). Counts are unaffected.
* **`include_tables`** (optional, default false) — Add the **table census** to the response: one `tables` row per queryable tab in scope (path, `component`, `row_count`, `column_count`, the owning file's `size_bytes`) plus `shared_columns` — column names appearing in several tables, each occurrence carrying its profiled `distinct_count`/`row_count`/`min_value`/`max_value`. Each tab row also carries **`table_name`** — the SQL view name `run-sql` registers it under. `shared_columns` are **unverified join candidates**: same name and compatible stats *suggest* a relation (equal distinct counts, a contained id range) — verify with one `run-sql` join before relying on one. One census call replaces a `file-metadata` call per file; it reads the ingestion profiles, so it is slower than a bare orientation call — use it once, then work per-file. Implies `recursive` the way the narrows do. Capped (`tables` at 500, `shared_columns` at 40 ranked most-shared then most key-like, 5 occurrences each) with `tables_truncated` / `shared_columns_truncated` reporting cuts — narrow the `directory` to recover.

```bash theme={"dark"}
curl -X POST "$NDI_BASE_URL/v1/workspaces/$WS/tools/folder-metadata" \
  -H "X-API-Key: $NDI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"directory": "/"}'
```

**Response:**

```json theme={"dark"}
{
  "directory": "/",
  "overall_files": 3,
  "categories": [
    {"name": "contracts_agreements/engagement_letter", "count": 1},
    {"name": "financial_reporting/balance_sheet", "count": 1}
  ],
  "subdirectories": [
    {
      "name": "execution",
      "overall_files": 177,
      "top_categories": [
        {"name": "source_vouchers/invoice", "count": 52},
        {"name": "source_vouchers/payment_evidence", "count": 35}
      ],
      "failed_files": 2
    }
  ],
  "ingestion_summary": {"ingested": 3},
  "files_truncated": false,
  "file_entries": [
    {
      "path": "balance_sheet.xlsx",
      "ingestion_status": "ingested",
      "size_bytes": 7692,
      "category": "financial_reporting/balance_sheet",
      "kind": "table"
    }
  ],
  "coverage": {"restricted_candidates": 0, "labels_required": []}
}
```

**Response fields:**

* **`file_entries`** — The listing: one page of visible source files, path-sorted. Each entry carries `ingestion_status`, `size_bytes`, `uploaded_at` (when the file was first registered — "what arrived recently" triage for PBC/legal delivery waves), and — when known — `category` and `kind` (the ingestion lane: `document` / `table` / `media`), so a folder can be triaged without a `file-metadata` call per file. `files_truncated` reports a cut page; resume with `start_after`, or better, narrow with a filter or a subdirectory.
* **`subdirectories`** — One summary per immediate subdirectory, always covering its whole subtree: `overall_files`, `top_categories` (most populous first, capped at 5), and `failed_files` when any ingestion failed there.
* **`ingestion_summary`** — File counts by ingestion status.
* **`coverage`** — Labels required to see withheld files.

Keys whose value would be `null` (an unclassified entry's `category`, a
subdirectory's zero `failed_files`) are omitted rather than sent as nulls.

The progressive loop in practice: the root call above says `execution/` holds
177 files, mostly invoices, 2 failed — so the next call is
`{"directory": "execution", "category": "source_vouchers/invoice"}`, and the
one after that is `file-metadata` on a candidate from its listing.

Three more one-call narrows that each replace a client-side scan of the
whole listing:

* **See what failed** — the counts advertise failures (`ingestion_summary.failed`,
  each subdirectory's `failed_files`); `{"directory": "/", "ingestion_status": "failed"}`
  lists exactly those files, ready for `file-metadata`, whose `ingestion_error`
  says why each one failed.
* **List every workbook** — `{"directory": "/", "kind": "table"}` returns each
  spreadsheet/CSV in the corpus (the files `qa-file` can query as tables).
* **Find files by name** — `{"directory": "/", "name_contains": "fabrinet"}`
  matches paths case-insensitively — including files whose ingestion failed,
  which no content search can retrieve.
* **Census the tables** — `{"directory": "/", "include_tables": true}` returns
  every queryable tab with its extent plus shared-column join hints — the one
  orientation call for a tabular corpus.

***

## File metadata

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

Everything about one uploaded file without opening it: its components, their
sizes and extents, and which content tool applies to each. This is the *decide*
step — call it before choosing between `read-file` and `qa-file`, and to learn
the component names that scope those calls.

**Request body:**

```json theme={"dark"}
{
  "path": "reports/financials.xlsx"
}
```

**Body fields:**

* **`path`** (required) — The uploaded file's path.

```bash theme={"dark"}
curl -X POST "$NDI_BASE_URL/v1/workspaces/$WS/tools/file-metadata" \
  -H "X-API-Key: $NDI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"path": "reports/financials.xlsx"}'
```

**Response:**

```json theme={"dark"}
{
  "path": "reports/financials.xlsx",
  "file_id": "550e8400-e29b-41d4-a716-446655440004",
  "kind": "table",
  "ingestion_status": "ingested",
  "size_bytes": 2097152,
  "tab_count": 2,
  "total_chars": 48210,
  "total_approx_bytes": 51000,
  "summary": "Quarterly revenue by segment, FY2024-FY2025.",
  "components": [
    {
      "kind": "tab",
      "name": "Revenue",
      "summary": "Quarterly revenue by segment, FY2024-FY2025.",
      "table_name": "financials_revenue",
      "row_count": 1204,
      "column_count": 8,
      "char_count": 40110,
      "readable": true,
      "queryable": true,
      "columns": [
        {"name": "segment", "data_type": "VARCHAR"},
        {"name": "quarter", "data_type": "VARCHAR"},
        {"name": "revenue", "data_type": "DOUBLE"}
      ]
    },
    {
      "kind": "tab",
      "name": "Notes",
      "summary": "Free-text commentary on the model's assumptions.",
      "char_count": 8100,
      "readable": true,
      "queryable": false
    }
  ]
}
```

**Response fields:**

* **`kind`** — Ingestion lane (`document` / `table` / `media`), when known.
* **`summary`** — The **primary segment's** (the first logical document's, or the first summarized sheet's), capped — never a concatenation across components. Each component carries its own. (Extraction keywords are deliberately not served: generic phrases that cost tokens without aiming any tool.)
* **`ingestion_error`** — Present on a failed file when the ledger recorded a reason, so a `"failed"` status is not a dead end.
* **`page_count`** / **`tab_count`** — Total pages (paged documents) / total tabs (spreadsheets).
* **`total_chars`** — Real character count of the file's text, present when ingestion measured one (new ingests). **`total_approx_bytes`** is the honest byte-based estimate that is always available; the two are never conflated.
* **`components`** — The file's public units, in the file's own order. Each carries:
  * **`kind`** — `"section"` (a PDF's classified segment), `"tab"` (a spreadsheet sheet), `"image"`, `"raw"` (a directly readable upload like CSV or markdown), or `"media"` (a recording — a separate shape, below).
  * **`name`** — The public component name (section name or tab name) to pass as `component` on `read-file` / `qa-file`. Empty when the whole file is the unit.
  * **`table_name`** — The SQL view name a queryable tab is registered under for `run-sql`; absent otherwise.
  * **`page_start`** / **`page_end`** — 1-based source page extent (sections); **`row_count`** / **`column_count`** for tabs.
  * **`char_count`** / **`approx_bytes`** — Real count when measured, byte estimate otherwise.
  * **`readable`** / **`queryable`** — Which content tool applies: `read-file` works on readable components, `qa-file`'s table engine on queryable ones (`qa-file` itself handles every kind, including images).
  * **`columns`** — A queryable tab's column schema **with the statistics ingestion profiled**: per column, `null_count`, `distinct_count` (APPROXIMATE — compare to `row_count` for key candidates; verify exact uniqueness with a query), `min_value`/`max_value` (clipped; id-format exemplars), `sum_value` (numeric columns), and either `top_values` (a frequency census — values WITH row counts, plus `top_values_coverage`) or `sample_values` (the complete value list, on profiles that predate censuses). Every rendered value is clipped server-side. This is the same briefing the platform's own SQL engine reads before writing a query — grain, keys, id formats, date ranges, and categorical vocabularies without probe queries. Fields are absent when the profile predates them; `columns` itself is absent when the component is not queryable or the profile is unavailable; `columns_truncated: true` marks a schema cut at the column cap. The access gate is the same one `read-file`/`query-tables` pass, so nothing here exceeds what those already serve.
  * **`error`** — A per-tab ingestion degradation, passed through. Such a component stays visible but has no readable or queryable content.
* A **media** component (`kind: "media"`) instead carries `modality` (`audio` / `video`), `duration_ms`, `speaker_count`, `speech_segment_count`, `frame_count`, and `summary`. Ask about a recording with `qa-file`.

Fields with nothing to say are **absent, not null**: a tab never carries
`page_start`, a section never carries `row_count`, and you will not see either
as null noise.

An **un-ingested file answers 200** with its `ingestion_status` and no
components — exactly what a caller needs to decide what to do next. A failed
one additionally carries `ingestion_error` with the recorded reason.

***

## Read file

```
POST /v1/workspaces/{workspace_id}/tools/read-file
```

Read the text of one uploaded file. A file with several components (a
spreadsheet's tabs, a PDF's sections) answers with their text concatenated in
the file's own order, each part under a one-line header naming the component
(e.g. `=== Tab: Revenue ===`).

**Request body:**

```json theme={"dark"}
{
  "path": "reports/annual-2025.pdf",
  "pages": null,
  "component": null,
  "max_chars": 200000
}
```

**Body fields:**

* **`path`** (required) — The uploaded file's path. A workspace-internal path is refused with `invalid_request` naming the uploaded file to use.
* **`pages`** (optional) — 1-based page numbers, on the file's own page numbering. Out-of-range pages are ignored (absent from `pages_returned`); selected pages come back de-duplicated in document order, whatever order they were asked in. Spreadsheets have no pages — scope those with `component` instead (`invalid_request` says so).
* **`component`** (optional) — Scope the read to one component by the name `file-metadata` lists (one tab, one section).
* **`max_chars`** (optional, default 200000, max 1000000) — Character budget for the returned content. On a multi-part read the budget is shared fairly across the selected parts, headers included.

```bash theme={"dark"}
curl -X POST "$NDI_BASE_URL/v1/workspaces/$WS/tools/read-file" \
  -H "X-API-Key: $NDI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"path": "reports/annual-2025.pdf", "pages": [1, 2, 3]}'
```

**Response:**

```json theme={"dark"}
{
  "path": "reports/annual-2025.pdf",
  "file_id": "550e8400-e29b-41d4-a716-446655440004",
  "content": "# Annual Report 2025\n\n## Executive Summary...",
  "pages_returned": [1, 2, 3],
  "truncated": false,
  "components_returned": ["Financial statements"],
  "components_omitted": []
}
```

**Response fields:**

* **`file_id`** — The uploaded file's ID.
* **`pages_returned`** — Pages actually included (may differ from requested if the document has fewer pages).
* **`truncated`** — `true` when the budget was hit before the end.
* **`components_returned`** / **`components_omitted`** — Which components contributed to `content` and which were cut for size (the budget ran out, or a part was too large to serve). Truncation is always visible — never a silently missing tab. Unnamed components (a recording, a raw upload) are listed by kind.

Images are refused (`unsupported_file_type`) — ask about them with `qa-file`,
which routes them to vision.

***

## QA file

```
POST /v1/workspaces/{workspace_id}/tools/qa-file
```

Ask a natural-language question about one uploaded file and get a direct, cited
answer. This replaces reading when you want an answer rather than the text —
and it is the one tool that works on **every** kind of file: prose, spreadsheets,
images, recordings.

The engine is picked from what the file (or the selected component) *is*:

| File / component                                                                              | Engine                                                                                                                                                              | `engine` value    |
| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- |
| A queryable table — a CSV/JSONL/parquet upload, a rows-only tab                               | Agentic DuckDB pipeline                                                                                                                                             | `"duckdb_agent"`  |
| An image                                                                                      | Vision QA                                                                                                                                                           | `"vision"`        |
| Text of any length — a section, a transcript, a markdown upload, a tab with no extracted rows | Recursive LLM built for long documents                                                                                                                              | `"recursive_llm"` |
| A file with several parts — a multi-tab workbook, a PDF split into sections                   | A QA agent that searches within the file, then reads or queries the relevant parts                                                                                  | `"agent"`         |
| A spreadsheet tab with both a rendered sheet and extracted rows                               | The same QA agent — the extracted rows hold only the detail table, while summary blocks and subtotals live in the rendered sheet, so a correct answer may need both | `"agent"`         |

**Request body:**

```json theme={"dark"}
{
  "path": "reports/financials.xlsx",
  "query": "What is total revenue for Q4 2025?",
  "include_citations": true,
  "component": null
}
```

**Body fields:**

* **`path`** (required) — The uploaded file's path. A workspace-internal path is refused with `invalid_request`.
* **`query`** (required, max 4096 chars) — Natural-language question.
* **`include_citations`** (optional, default `true`) — Attach source evidence.
* **`component`** (optional) — Scope the question to one component by the name `file-metadata` lists. Scoping a multi-tab workbook down to one tab routes the question straight to that tab's engine.

```bash theme={"dark"}
curl -X POST "$NDI_BASE_URL/v1/workspaces/$WS/tools/qa-file" \
  -H "X-API-Key: $NDI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"path": "reports/financials.xlsx", "query": "What is total revenue for Q4 2025?", "component": "Revenue"}'
```

**Response:**

```json theme={"dark"}
{
  "answer": "Q4 2025 total revenue was $1.08B, up 9% from Q4 2024.",
  "citations": [
    {
      "file_id": "550e8400-e29b-41d4-a716-446655440005",
      "path": "reports/financials.xlsx",
      "locator": {"kind": "spreadsheet_range", "sheet": "Revenue", "a1_range": "B5"},
      "relevance_score": 0.97,
      "snippet": "Q4 2025: Revenue: 1,082,341,000",
      "why": "Direct row for Q4 2025 revenue",
      "component": "Revenue"
    }
  ],
  "engine": "duckdb_agent",
  "unavailable": []
}
```

**Response fields:**

* **`answer`** — The direct answer. It refers to your file, its tab/section names, and page numbers — never to anything workspace-internal.
* **`citations`** — Source evidence; `path` is always the uploaded file, and `component` names the tab/section the citation came from (reusable as `component=` on a follow-up `qa-file` / `read-file`; `null` for single-component files or when attribution is unavailable).
* **`engine`** — Which pipeline answered: `"recursive_llm"`, `"duckdb_agent"`, `"vision"`, or `"agent"`.
* **`unavailable`** — Tables that could not be queried (a tab that failed to ingest), with `error` and `error_kind`. A failed tab does not fail the whole call.

***

### Table files and the component escape hatch

A whole **table file** (spreadsheet or record upload) is refused with a typed
redirect — its rows belong to `run-sql` / `query-tables`. The exception is a
named `component`: prose that lives in a workbook (a policy tab, a notes
sheet) is askable per tab — `qa-file` with `component=<tab>` runs the document
engine over that tab's **rendered text** (queryable row extractions stay with
the table engines). A component with no rendered text is refused, naming
`run-sql`.

***

## Query tables

```
POST /v1/workspaces/{workspace_id}/tools/query-tables
```

Ask one natural-language question across several uploaded spreadsheets or
tabular files. NDI resolves each source through the same access and ingestion
rules as `qa-file`, runs the table query, and retains generated result tables
as downloadable Parquet files.

**Request body:**

```json theme={"dark"}
{
  "paths": ["reports/financials.xlsx", "reports/budget.csv"],
  "query": "Which department is most over budget?",
  "include_citations": true
}
```

**Body fields:**

* **`paths`** (required, 1–32) — Workspace-relative paths of uploaded tabular source files.
* **`query`** (required, max 4096 chars) — Natural-language question over all selected tables.
* **`include_citations`** (optional, default `true`) — Include source evidence.
* **`components`** (optional) — Tab names scoping every workbook path to just those
  tabs instead of every sheet. The names are the same strings `file-metadata` and
  filtered-search publish as `component` (and `qa-file` accepts as `component=`),
  matched per path: exact tab name first, then the unambiguous sanitized alias. A
  matched tab with no queryable derivative is reported in `unavailable` rather than
  silently dropped, as is a workbook none of the names match; a name matching no
  requested path at all — or a filter over a single-table upload (csv/jsonl/parquet),
  which has no named components — is `invalid_request`.

```bash theme={"dark"}
curl -X POST "$NDI_BASE_URL/v1/workspaces/$WS/tools/query-tables" \
  -H "X-API-Key: $NDI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"paths": ["reports/financials.xlsx", "reports/budget.csv"], "query": "Which department is most over budget?"}'
```

**Response:**

```json theme={"dark"}
{
  "answer": "Marketing is most over budget by $3.4M.",
  "error": null,
  "timed_out": false,
  "citations": [],
  "result_citations": [
    {
      "result_file": {
        "file_id": "550e8400-e29b-41d4-a716-446655440040",
        "handle": "overspend_by_department",
        "path": "reports/query-results/overspend_by_department.parquet",
        "file_name": "overspend_by_department.parquet",
        "format": "parquet",
        "row_count": 8,
        "download_url": "https://...",
        "preview_url": "https://...",
        "download_url_expires_at": "2026-08-17T12:15:00Z"
      },
      "source_paths": ["reports/budget.csv", "reports/financials.xlsx"],
      "sql": "SELECT department, SUM(actual - budget) AS variance ..."
    }
  ],
  "result_materialization_error": null,
  "unavailable": [],
  "tables": [
    {
      "path": null,
      "handle": "overspend_by_department",
      "row_count": 8,
      "columns": [
        {"name": "department", "data_type": "VARCHAR"},
        {"name": "variance", "data_type": "DOUBLE"}
      ],
      "rows": [{"department": "Marketing", "variance": "3400000"}],
      "preview_truncated": false
    }
  ]
}
```

**Response fields:**

* **`answer`** — Natural-language answer across the selected sources.
* **`error`** / **`timed_out`** — Query failure details without discarding any partial result.
* **`citations`** — Evidence from the uploaded source files.
* **`result_citations`** — Generated result files, the source paths behind them, and the SQL used.
* **`result_materialization_error`** — Set when a result could not be retained for download.
* **`unavailable`** — Inputs that could not be queried, with `error` and `error_kind`.
* **`tables`** — Bounded inline previews; use `result_citations[].result_file.download_url` for the full Parquet result.

***

## Run SQL

```
POST /v1/workspaces/{workspace_id}/tools/run-sql
```

The fast lane next to `query-tables`: no planner and no answering agent — your
statement runs directly in the same guarded SQL engine, against **every
queryable tab in the workspace** registered as a view under the `table_name`
that `file-metadata` and the folder-metadata table census publish. DuckDB
dialect, read-only, any number of tables per statement; joins across files
are ordinary SQL joins.

**Request body:**

```json theme={"dark"}
{"sql": "SELECT department, SUM(actual - budget) AS variance FROM budget GROUP BY department ORDER BY variance DESC"}
```

**Response fields:**

* **`sql`** — The normalized statement that ran.
* **`result`** — The preview: `handle`, `row_count`, `columns`, bounded
  `rows`, and `preview_truncated` when the stored result holds more.
* **`duplicate`** — `true` when an identical statement's stored result
  answered without re-running anything.
* **`result_citation`** — The retained result: `result_file.download_url`
  fetches it whole as Parquet, `source_paths` name the files the statement
  read. `null` when retention failed — see the next field.
* **`result_materialization_error`** — Set when the result could not be
  retained for download; the inline preview still stands, and paging with
  `LIMIT`/`OFFSET` still works.

Two properties worth designing around:

* **Results are content-addressed and chainable.** Every result's `res_…`
  handle is itself a registered table on your next statement — join it,
  aggregate it, or page it with `LIMIT`/`OFFSET`. Repeating an identical
  statement is answered from the stored result (`duplicate: true`) without
  re-scanning anything.
* **The guard is the error message.** A non-SELECT statement, or one reading
  a table outside the registered set, is refused with `invalid_request`
  naming the rule broken and the registered table names. When two files'
  tabs settled on the same `table_name`, each is registered under a
  per-file suffixed name (`gl_1`, `gl_2`, …) and the bare name is refused
  with the file-to-name mapping — ambiguity never resolves silently.

***

## Choosing between tools

| Tool                | When to use                                                                    |
| ------------------- | ------------------------------------------------------------------------------ |
| **folder-metadata** | Orient: what a directory holds, where to descend, what to narrow by            |
| **file-metadata**   | Decide: what's inside one file, which tool and component to use                |
| **read-file**       | You want the text itself (paged documents, tabs, transcripts)                  |
| **qa-file**         | You want an answer about the file — any kind, any size                         |
| **run-sql**         | You write the SQL yourself — fast, exact, chainable results                    |
| **query-tables**    | You want the engine to plan and answer a natural-language question over tables |

For questions spanning **many files**, use
[fact-search or deep-search](/api-reference/intelligent-search) instead;
for ranked passages without synthesis, use
[hybrid-search](/api-reference/search-tools).
