Files
The bytes a project works from — uploaded, listed, tagged, downloaded, deleted.
Overview
A file is stored content plus the metadata needed to find it again: a path
within the project, a MIME type, a size, and tags. It is the raw layer under the
retrieval stack — a document is created from a file, and it
is where a trace parks its serialized steps.
Uploads come in two shapes. multipart/form-data is the one to use for real
files: the bytes stream through as they are. Base64 is the one to use from a
client that cannot send multipart — an MCP tool call, a shell one-liner — at the
cost of roughly a third more bytes on the wire. Downloads mirror that split.
This module is a verbatim mirror of the runtime: every field, method, status code and error shape is the runtime's own, re-rooted under the project in the path.
Free stores 1 GB, Pro 30 GB, Business 100 GB; an Enterprise ceiling is set by
contract. Uploading past that answers 403 plan_limit_reached with
resource: "storage", and the plan, the limit and what your account is
holding as storage_gb in details.
The ceiling is your account's, not each project's: every project the
account pays for draws on the same figure, so one project may hold all of it.
Read where you stand from storage_gb and storage_limit_gb on
GET /v1/users/me/billing; the
usage route below reports one project at a time.
The figure counts indexed storage — uploaded files plus the chunks and embedding
vectors of anything ingested from them — so a file that backs a document counts
for more than its own size. See
Documents for how that works, and read your current
figure from the gb_day component of
GET /v1/projects/{project_id}/usage
under meter_type=storage.
Downloading, updating metadata and deleting stay open at the ceiling; only uploads are refused.
See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → Files.
Data Model
FileRecord
| Field | Type | Description |
|---|---|---|
id | string | Public file ID (file_ prefix). |
prefix | string | The directory part of path. Read-only in the response; set it with prefix on upload. |
filename | string | Original / download name, and the last segment of path. |
path | string, nullable | prefix + / + filename (e.g. /images/logo.png). Read-only, and unique per project — the file's identity. |
content_type | string, nullable | MIME type. |
size | integer, nullable | Size in bytes. |
metadata | string, nullable | A JSON string, not an object — see Metadata is a string here. |
tags | object | Key-value tags, values are strings. |
created_at | string (date-time) | |
updated_at | string (date-time) |
Key Concepts
Two ways to upload, two ways to download
POST /v1/projects/{project_id}/files/upload
takes multipart/form-data with the bytes in a file part, plus optional
prefix, filename and metadata parts. Nothing is re-encoded on the way
through, so this is the form to use for anything large or binary.
POST /v1/projects/{project_id}/files/upload/base64
takes JSON with the content in content. Every client can produce it, which is
why it exists — the CLI and the MCP tool surface use it, as does
Answer from your documents —
but base64 inflates
the payload by about 33%, so prefer multipart when the client can.
Downloading is the same choice:
GET /v1/projects/{project_id}/files/{file_id}/download
answers the raw bytes under the file's own content type, with
content-disposition carrying the filename;
GET /v1/projects/{project_id}/files/{file_id}/download/base64
answers JSON.
A tool result is a JSON value, and this route's is a stream of bytes, so the raw download is deliberately absent from the MCP tool surface. The base64 download is the callable form and returns the same content.
Creating a record without bytes
POST /v1/projects/{project_id}/files registers a
file record — prefix, filename, content_type, size, metadata — with no
content attached. It is for the case where the bytes arrive by some other route
and only the entry is needed. If you have the bytes, upload them: one call
instead of two, and the record is consistent with what was actually stored.
Metadata is a string here
metadata on a file is a JSON string, unlike the metadata object on most
other resources. Send it serialized ('{"author":"Ada"}') and parse it on the
way out. The value is stored verbatim, so keys keep the casing you wrote them
in.
Tags are a separate, first-class surface: read them with
GET /v1/projects/{project_id}/files/{file_id}/tags,
replace the whole set with
PUT /v1/projects/{project_id}/files/{file_id}/tags,
or merge into it with
PATCH /v1/projects/{project_id}/files/{file_id}/tags.
PATCH only ever adds or overwrites the keys you send; PUT drops the ones you
leave out.
Who may do what
Every route needs any project member.
Examples
Upload a file
- CLI
- SDK
- curl
naturali upload-file-base64 \
--project-id proj_V1StGXR8Z5jdHi6B \
--content "$(base64 -w0 handbook.md)" \
--filename handbook.md \
--prefix /docs \
--content-type text/markdown
import { readFileSync } from 'node:fs';
const { data: file } = await naturali.files.uploadFileBase64({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: {
content: readFileSync('handbook.md').toString('base64'),
filename: 'handbook.md',
prefix: '/docs',
content_type: 'text/markdown',
},
});
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/files/upload \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-F file=@handbook.md \
-F prefix=/docs
List the project's files
- CLI
- SDK
- curl
naturali list-files \
--project-id proj_V1StGXR8Z5jdHi6B \
--limit 20
const { data: files } = await naturali.files.listFiles({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { limit: 20 },
});
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/files?limit=20" \
-H "Authorization: Bearer $NATURALI_TOKEN"
Download the content
- CLI
- SDK
- curl
naturali download-file-base64 \
--project-id proj_V1StGXR8Z5jdHi6B \
--file-id file_V1StGXR8Z5jdHi6B
const { data: encoded } = await naturali.files.downloadFileBase64({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
file_id: 'file_V1StGXR8Z5jdHi6B',
},
});
curl -O -J https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/files/file_V1StGXR8Z5jdHi6B/download \
-H "Authorization: Bearer $NATURALI_TOKEN"
Delete a file
- CLI
- SDK
- curl
naturali delete-file \
--project-id proj_V1StGXR8Z5jdHi6B \
--file-id file_V1StGXR8Z5jdHi6B
await naturali.files.deleteFile({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
file_id: 'file_V1StGXR8Z5jdHi6B',
},
});
curl -X DELETE https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/files/file_V1StGXR8Z5jdHi6B \
-H "Authorization: Bearer $NATURALI_TOKEN"