Skip to main content
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-metadataorient: 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-metadatadecide: what is inside one file, how big is each part, which tool applies?
  3. read-file / qa-fileact: 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

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:
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.
Response:
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

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:
Body fields:
  • path (required) — The uploaded file’s path.
Response:
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

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:
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.
Response:
Response fields:
  • file_id — The uploaded file’s ID.
  • pages_returned — Pages actually included (may differ from requested if the document has fewer pages).
  • truncatedtrue 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

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: Request body:
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.
Response:
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

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:
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.
Response:
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

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:
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.
  • duplicatetrue 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

For questions spanning many files, use fact-search or deep-search instead; for ranked passages without synthesis, use hybrid-search.