> ## 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.

# Files

> Source file and artifact management — 10 endpoints

Files are the primary resource in a workspace. Uploading a file registers it in the
ledger but does **not** make it searchable — ingestion does that. All write operations
return a `Job`.

All list routes use **keyset cursor pagination** (`cursor` / `next_cursor`).

***

## Upload file

```
POST /v1/workspaces/{workspace_id}/files
```

Register a file in the workspace. Two content paths are supported:

**Multipart upload (bytes):**

```bash theme={"dark"}
curl -X POST "$NDI_BASE_URL/v1/workspaces/$WS/files" \
  -H "X-API-Key: $NDI_API_KEY" \
  -F "file=@annual-report.pdf" \
  -F 'metadata={"path":"reports/annual-report.pdf","labels":{"year":"2025"}}'
```

The `metadata` form field must be a JSON-encoded `UploadFileRequest`.

**JSON upload (server-side URL fetch):**

```bash theme={"dark"}
curl -X POST "$NDI_BASE_URL/v1/workspaces/$WS/files" \
  -H "X-API-Key: $NDI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "path": "reports/annual-report.pdf",
    "source": {"type": "url", "url": "https://example.com/report.pdf", "file_name": "report.pdf"},
    "labels": {"year": "2025"}
  }'
```

**UploadFileRequest fields:**

* **`path`** (required) — Workspace-relative destination path. Must not start with `/`. No `.` or `..` segments.
* **`source`** (optional) — `{"type": "url", "url": "...", "file_name": "..."}` for server-side fetch. Omit for multipart.
* **`labels`** (optional) — Key-value metadata stored verbatim.
* **`graph_inclusion`** (optional, default `"auto"`) — `"auto"`, `"include"`, or `"exclude"`. Overrides the workspace KG exclusion rules for this file.
* **`on_conflict`** (optional, default `"reject"`) — `"reject"` raises `path_conflict` if a file already exists at this path. `"new_version"` creates a new version instead.

**`wait_seconds`** and **`Idempotency-Key`** are supported as query param and header respectively.

**Response (200 — job already succeeded):**

```json theme={"dark"}
{
  "job_id": "550e8400-e29b-41d4-a716-446655440020",
  "kind": "file_upload",
  "status": "succeeded",
  "result": {
    "result_type": "file_upload",
    "file": {
      "file_id": "550e8400-e29b-41d4-a716-446655440004",
      "workspace_id": "550e8400-e29b-41d4-a716-446655440003",
      "path": "reports/annual-report.pdf",
      "file_name": "annual-report.pdf",
      "file_type": "application/pdf",
      "size_bytes": 2097152,
      "observed_hash": "a3f2b1c0d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
      "ingestion_status": "discovered",
      "version": 1,
      "labels": {"year": "2025"},
      "created_at": "2026-08-09T12:00:00Z",
      "updated_at": "2026-08-09T12:00:00Z"
    },
    "deduplicated": false
  },
  "created_at": "2026-08-09T12:00:00Z"
}
```

Upload does **not** ingest. The file lands with `ingestion_status: "discovered"` and is
not searchable until you call `POST .../ingestions`.

**Errors:**

* **`path_conflict`** (409) — File already exists at this path and `on_conflict="reject"`.
* **`file_too_large`** (413) — Upload exceeds the server maximum.

***

## List files

```
GET /v1/workspaces/{workspace_id}/files
```

Page through source files in the workspace.

**Query parameters:**

* **`cursor`** (optional) — Opaque cursor from `next_cursor`.
* **`limit`** (optional, default 50, max 200)

**Response:**

```json theme={"dark"}
{
  "items": [
    {
      "file_id": "550e8400-e29b-41d4-a716-446655440004",
      "workspace_id": "550e8400-e29b-41d4-a716-446655440003",
      "path": "reports/annual-report.pdf",
      "file_name": "annual-report.pdf",
      "file_type": "application/pdf",
      "size_bytes": 2097152,
      "observed_hash": "a3f2b1c0d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
      "ingested_hash": "a3f2b1c0d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
      "ingestion_status": "ingested",
      "ingested_at": "2026-08-09T12:05:00Z",
      "version": 1,
      "category": "financial/balance_sheet",
      "access_label": "financial",
      "labels": {},
      "created_at": "2026-08-09T12:00:00Z",
      "updated_at": "2026-08-09T12:05:00Z"
    }
  ],
  "next_cursor": null,
  "total_count": 1,
  "coverage": {"restricted_candidates": 0, "labels_required": []}
}
```

`total_count` is exact after access filtering. `coverage` reports how many files were
withheld by the access predicate and which labels would unlock them.

***

## Get file

```
GET /v1/workspaces/{workspace_id}/files/{file_id}
```

Retrieve file metadata plus short-lived presigned download and preview URLs.

**Response:** A `FileDetail` — the `File` fields above plus:

* **`download_url`** — Presigned GET URL that saves the file to disk.
* **`preview_url`** — Presigned GET URL that browsers render inline.
* **`download_url_expires_at`** — When both URLs expire.

***

## Get file metadata

```
GET /v1/workspaces/{workspace_id}/files/{file_id}/metadata
```

Detailed file information including ingestion-derived metadata and all live outputs.

**Response:**

```json theme={"dark"}
{
  "file": { ... },
  "lane": "document",
  "page_count": 96,
  "sheet_names": null,
  "duration_ms": null,
  "summary": "Annual report covering FY2025 financial results.",
  "keywords": ["revenue", "earnings", "EBITDA"],
  "documents": [...],
  "sheets": [],
  "media": null,
  "extracted_fields": [],
  "outputs": [
    {
      "output_id": "550e8400-e29b-41d4-a716-446655440030",
      "kind": "derived_file",
      "path": "reports/annual-report/output.md",
      "generation": 1,
      "created_at": "2026-08-09T12:05:00Z"
    }
  ],
  "representations": {
    "organized_files": true,
    "search_index": true,
    "knowledge_graph": true
  },
  "last_error": null
}
```

***

## Get file output

```
GET /v1/workspaces/{workspace_id}/files/{file_id}/outputs/{output_id}
```

Retrieve one derived output with a presigned download URL.

**Response:** An `ArtifactOutputDetail` — the output fields above plus `download_url`, `preview_url`, `download_url_expires_at`.

***

## Get artifact content by path

```
GET /v1/workspaces/{workspace_id}/artifacts/content?path={workspace_relative_path}
```

Stream the current bytes for a source file or derived output by its exact
workspace-relative path. This authenticated route works across managed local
and S3 storage and reapplies the file's access-label rules on every request.

Use your HTTP client's query-parameter support so `/`, Unicode, spaces, and
reserved characters in `path` are encoded correctly:

```bash theme={"dark"}
curl --get "$NDI_BASE_URL/v1/workspaces/$WS/artifacts/content" \
  -H "X-API-Key: $NDI_API_KEY" \
  --data-urlencode "path=reports/annual-report/output.md"
```

**Query parameters:**

* **`path`** (required) — Exact workspace-relative path of a current source or derivative.

**Headers:**

* **`Range`** (optional) — One byte range, such as `bytes=0-1023`. A satisfiable range returns `206 Partial Content`.

The response streams the artifact with its detected media type, `Content-Length`,
`Accept-Ranges: bytes`, and an inline `Content-Disposition`.

**Errors:**

* **`file_not_found`** (404) — No current visible artifact or stored bytes exist at the path.
* **`range_not_satisfiable`** (416) — The `Range` header is malformed, contains multiple ranges, or selects bytes outside the artifact.

***

## List output members

```
GET /v1/workspaces/{workspace_id}/files/{file_id}/outputs/{output_id}/members
```

For outputs that are folders (e.g., extracted video frames), list member objects.

**Query parameters:**

* **`offset`** (optional, default 0) — Members to skip, for paging a large folder.
* **`limit`** (optional, default 50, max 200)

**Response:**

```json theme={"dark"}
{
  "members": [
    {
      "path": "reports/annual-report/frames/frame_000001.jpg",
      "file_name": "frame_000001.jpg",
      "size_bytes": 65536,
      "download_url": "https://...",
      "preview_url": "https://..."
    }
  ],
  "total_count": 3600,
  "download_url_expires_at": "2026-08-09T12:15:00Z"
}
```

***

## Get file versions

```
GET /v1/workspaces/{workspace_id}/files/{file_id}/versions
```

View the version history of a file.

**Response:**

```json theme={"dark"}
{
  "items": [
    {
      "version": 2,
      "observed_hash": "b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5",
      "ingested_hash": "b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5",
      "ingested_at": "2026-08-09T13:05:00Z",
      "size_bytes": 2200000,
      "created_at": "2026-08-09T13:00:00Z",
      "replaced_at": null,
      "is_current": true
    },
    {
      "version": 1,
      "observed_hash": "a3f2b1c0d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
      "ingested_hash": "a3f2b1c0d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
      "ingested_at": "2026-08-09T12:05:00Z",
      "size_bytes": 2097152,
      "created_at": "2026-08-09T12:00:00Z",
      "replaced_at": "2026-08-09T13:00:00Z",
      "is_current": false
    }
  ],
  "next_cursor": null,
  "total_count": 2
}
```

***

## Replace file

```
PUT /v1/workspaces/{workspace_id}/files/{file_id}
```

Replace a file's bytes. Must use `multipart/form-data`: bytes in a `file` part and
optional parameters as JSON-encoded `metadata` form field. There is no JSON-only path
for labels-only updates — send the new bytes with updated metadata.

**Multipart:**

```bash theme={"dark"}
curl -X PUT "$NDI_BASE_URL/v1/workspaces/$WS/files/$FILE_ID" \
  -H "X-API-Key: $NDI_API_KEY" \
  -F "file=@report-v2.pdf" \
  -F 'metadata={"labels":{"year":"2025","revised":"true"}}'
```

**ReplaceFileRequest fields (JSON-encoded in the `metadata` form field):**

* **`labels`** (optional) — New labels. Omit to leave existing labels unchanged. Pass `{}` to clear all.
* **`graph_inclusion`** (optional) — Change this file's KG inclusion. Omit to leave unchanged.

**Response:** A `Job` with `kind: "file_replace"`.

***

## Delete file

```
DELETE /v1/workspaces/{workspace_id}/files/{file_id}
```

Delete a file and cascade all derived outputs asynchronously.

**Response (202 Accepted):** A `Job` with `kind: "file_delete"`.
