Skip to main content

Files

File upload, download, metadata management, and deletion over a pluggable storage backend (local filesystem, S3, or GCS).

Overview​

Files belong to a project and are persisted through the configured backend (local filesystem, S3, or GCS). Records expose a public id, never the internal key; metadata lives in PostgreSQL, while physical location and backend are system-managed (see Configuration).

See the Permissions Reference for the IAM action strings for this module.

Data Model​

FieldTypeDescription
idstringPublic identifier
prefixstringDirectory within the project (e.g. /assets). Optional on write; defaults to / (root). Read-only on the record (derived from path).
filenamestringOriginal / download name and the key's leaf segment (e.g. logo.png). Optional on write; defaults to the uploaded file's name.
pathstring | nullRead-only. Full key = prefix + / + filename (e.g. /assets/logo.png). Unique per project; the file's identity and the resource ID segment in path-based SRNs.
content_typestringMIME type
sizenumberFile size in bytes
metadataobject | nullCaller-owned annotations, stored as the object they were written as — see Tags and metadata
project_idstringID of the owning project
created_atstringISO 8601 creation timestamp
updated_atstringISO 8601 last-updated timestamp

path is normalized at write time, unique per project, and the target of path-based SRNs. Change prefix to move, filename to rename; either rebuilds path. A collision returns 409 NAME_CONFLICT.

Key Concepts​

Tags​

Key-value string pairs managed via the tag sub-endpoints and matched by soat:ResourceTag/<key>. GET /api/v1/files filters by pair with ?tags=key:value (repeatable, all must match). See IAM — Tags.

Metadata​

metadata is a JSON object, stored as the object it was written as: the types a value was written with are the types a read returns, and keys keep the casing they were supplied in. The platform never reads the bag — Tags and metadata draws that line — so nothing in it reaches an access decision.

Every surface carries the same object. PATCH /api/v1/files/{file_id}/metadata replaces the stored bag, and null clears it. A multipart upload is the one exception on the wire, not in the store: a form field carries text, so POST /api/v1/files/upload and a multipart POST /api/v1/files/upload/{token} take the object as JSON text and parse it on arrival. Text that is not a JSON object is 400 VALIDATION_FAILED, as a body field that is not an object is.

Storage Backends​

Bytes are handled by a storage provider selected with FILES_STORAGE_PROVIDER (default local); the API is identical across backends. Each file records its backend, so reads and deletes route correctly after the active backend changes.

ProviderFILES_STORAGE_PROVIDERWhere bytes live
Local filesystemlocal (default)A project-scoped directory tree under FILES_STORAGE_DIR
S3 / S3-compatibles3Objects in the bucket named by FILES_S3_BUCKET

Both use the layout {projectPublicId}/{category}/{fileId}{ext}:

SegmentDescription
projectPublicIdPublic project ID (e.g. proj_ABC) — isolates files by project
categoryDerived from the first segment of the file's logical path (e.g., /reports/q1.txt → reports/)
fileIdThe file's public ID
extFile extension from the original filename

Every writer builds this key the same way. ext describes the stored bytes: a document's text object is always .txt.

Without a path, the category is files/. Local: a path under FILES_STORAGE_DIR; S3: the object key, optionally under FILES_S3_KEY_PREFIX:

# local: {FILES_STORAGE_DIR}/{projectPublicId}/{category}/{fileId}{ext}
/data/files/proj_1a123a/reports/file_abc123.txt
/data/files/proj_1a123a/documents/doc_xyz.md

# s3: s3://{FILES_S3_BUCKET}/{FILES_S3_KEY_PREFIX}/{projectPublicId}/{category}/{fileId}{ext}
s3://my-bucket/proj_1a123a/reports/file_abc123.txt

The reserved /.system/ root​

Every module that saves a file or document on your behalf files it under /.system/<module>/, one directory per module:

PathWritten by
/.system/conversations/{conversation_id}/{document_id}.txtEach conversation message
/.system/traces/{trace_id}.jsonEach trace's raw steps

The root is yours to read and not to write. A POST, a path move or a tag write that lands under it is refused with 400 RESERVED_PATH, so the marker cannot be edited off a row; the owning module deletes what it wrote — deleting a conversation deletes its turn documents, and so on.

Deleting is the one write that is yours: DELETE on a document or file inside the root is served, so a row whose owner is gone can still be cleared.

A list leaves the root out unless you ask for it: GET /api/v1/files and GET /api/v1/documents return platform-written rows only when path_prefix names a directory inside it, and knowledge search ranks them only when document_paths does. A read by id always returns one — the tutorial that downloads raw trace steps by file_id needs no prefix. See Debug Session, Generation, and Trace History - Step 6 (Download raw trace steps).

Path-Based SRNs​

Policies can target files by path; with a path set, both the id-based and path-based SRN are evaluated:

SRN formMatches
srn:proj_ABC:file:file_XYZSpecific file by ID
srn:proj_ABC:file:/assets/logo.pngFile at the exact path /assets/logo.png
srn:proj_ABC:file:/exports/*All files under /exports/
srn:proj_ABC:file:*All files in the project (id wildcard)

List queries apply policy filters in SQL. SRN syntax: IAM; worked example: Permissions in Practice - Step 7 (Verify permissions with file operations).

Upload Tokens (decoupled uploads)​

A two-step flow, the local-storage equivalent of an S3 presigned URL, usable from any client:

  1. Request a token — POST /api/v1/files/presigned-url returns a single-use upload_token, upload_url, and expires_at (15 minutes). Authenticated; requires files:UploadFile. upload_url is relative (e.g. /api/v1/files/upload/upt_xxx) unless SOAT_BASE_URL is set, in which case it is absolute (see Configuration).
  2. Upload the content — POST /api/v1/files/upload/{token} writes the file and returns the record. No bearer credential; the token is the credential. Accepts multipart/form-data (field file) or JSON with base64 content.

The authorizing party (step 1) need not transfer the bytes (step 2): hand the token to a browser, worker, or CLI.

A token is invalidated after one successful upload: reuse 409, expired 410, unknown 404.

Downloading from a tool​

GET /api/v1/files/{file_id}/download streams raw bytes and is REST/SDK/CLI only (no JSON form, so not an MCP or builtin action). Tools use download-file-base64, which returns the content base64-encoded in JSON, subject to the client's tool-call payload limit; an agent should fetch large files out-of-band.

Large files via MCP​

MCP payloads above ~100 KB are truncated, so upload-file-base64 cannot carry a large file. Use the token flow: create-presigned-url (an MCP tool) is small; do step 2 out-of-band (curl, a fetch/HTTP tool, or the SDK) with multipart/form-data streamed from disk:

# Step 1 returned upload_url = /api/v1/files/upload/upt_xxx
curl -F "file=@/path/to/large-report.pdf" "$BASE_URL/api/v1/files/upload/upt_xxx"

Configuration​

Environment VariableRequiredDescription
FILES_STORAGE_PROVIDERNoStorage backend: local (default) or s3. Selects where new files are written.
FILES_STORAGE_DIRFor localAbsolute path to the directory where uploaded files are stored. Must be writable by the server process. Required when the provider is local.
FILES_S3_BUCKETFor s3Name of the S3 bucket that stores file objects. Required when the provider is s3.
FILES_S3_REGIONNoAWS region of the bucket. Falls back to AWS_REGION if unset.
FILES_S3_KEY_PREFIXNoKey prefix prepended to every object, to namespace files within a shared bucket (e.g. soat/).
FILES_S3_ENDPOINTNoCustom endpoint URL for S3-compatible stores (e.g. MinIO, Cloudflare R2). Omit for AWS S3.
FILES_S3_FORCE_PATH_STYLENoSet to true to use path-style bucket addressing (required by some S3-compatible stores).
FILE_UPLOAD_MAX_BYTESNoCeiling on a multipart upload, in bytes. Defaults to 26214400 (25 MB). A larger body is refused with UPLOAD_TOO_LARGE (413) while it is still streaming, so nothing is buffered or stored.
SOAT_BASE_URLNoPublic base URL of the server (e.g. https://api.example.com). When set, the presigned-URL flow returns an absolute upload_url; otherwise the URL is relative. A trailing slash is trimmed.

AWS credentials for s3 resolve through the standard AWS SDK chain (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, shared profile, or instance/task role).

With the local backend in Docker, mount a volume at FILES_STORAGE_DIR:

services:
server:
image: soat-server
environment:
FILES_STORAGE_DIR: /data/files
volumes:
- files-data:/data/files

volumes:
files-data:

For S3, set provider and bucket (no volume):

services:
server:
image: soat-server
environment:
FILES_STORAGE_PROVIDER: s3
FILES_S3_BUCKET: my-soat-files
FILES_S3_REGION: us-east-1

Examples​

Upload a file (base64)​

soat upload-file-base64 \
--project-id proj_ABC \
--content "iVBORw0KGgo..." \
--prefix /assets \
--filename logo.png

Upload a file via an upload token​

# Step 1 — request a single-use token
TOKEN=$(soat create-presigned-url \
--project-id proj_ABC \
--content-type application/pdf \
--prefix /documents \
--filename report.pdf | jq -r .upload_token)

# Step 2 — upload the content directly (no payload limit)
soat upload-file-with-token \
--token "$TOKEN" \
--content "$(base64 -w0 report.pdf)"

List files in a project​

soat list-files --project-id proj_ABC