File attachments (OpenAI-compatible storage)

Coulisse exposes a /v1/files API that matches the OpenAI Files API shape exactly. Any OpenAI-compatible SDK works without modification.

What this lets you do

  • Upload a file once, reference it by file_id in any subsequent chat request.
  • Pass multimodal content (images, PDFs, text) to an LLM backend that supports it — Coulisse stores the file and forwards it transparently.
  • Set a storage quota so the disk never fills up (oldest files evicted first).

Endpoints

MethodPathDescription
POST/v1/filesUpload a file (multipart/form-data)
GET/v1/filesList all uploaded files
GET/v1/files/:idGet metadata for one file
GET/v1/files/:id/contentDownload file content
DELETE/v1/files/:idDelete a file (idempotent)

Upload example

curl -X POST http://localhost:3000/v1/files \
  -F "file=@cv.pdf;type=application/pdf" \
  -F "purpose=assistants"

Response:

{
  "id": "file-01j9abc...",
  "object": "file",
  "bytes": 42381,
  "created_at": 1722000000,
  "filename": "cv.pdf",
  "purpose": "assistants",
  "content_type": "application/pdf"
}

Then reference the file in a chat request:

{
  "model": "gpt-4o",
  "messages": [{
    "role": "user",
    "content": [
      { "type": "text", "text": "Summarise this CV in three bullet points." },
      { "type": "input_file", "file_id": "file-01j9abc..." }
    ]
  }]
}

Configuration

Add a storage: block to coulisse.yaml. Everything has a default — if you omit the block, the filesystem backend is used with no quota. Filesystem blobs always live under .coulisse/files, next to your config; the path is not configurable.

storage:
  backend: fs           # "fs" (default) or "s3"
  max_file_bytes: 52428800   # 50 MB per file — omit for no limit
  max_total_bytes: 524288000 # 500 MB total — omit for no limit

Docker: mount the .coulisse/ directory to persist uploaded files (and the rest of Coulisse's state) across container restarts.

S3-compatible backend

Swap backend: s3 to store blobs in AWS S3, Cloudflare R2, or MinIO:

storage:
  backend: s3
  s3:
    bucket: my-coulisse-files
    region: eu-west-3
    # endpoint_url: http://localhost:9000  # for MinIO / local S3
  max_file_bytes: 52428800

Credentials are read from the standard AWS credential chain (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env vars, IAM role, ~/.aws/credentials, etc.).

Note: Set endpoint_url when using MinIO or another self-hosted S3-compatible service — path-style addressing is enabled automatically in that case.

Allowed file types

Coulisse validates file content via magic bytes (not just the declared Content-Type) and rejects anything outside this list:

  • text/*
  • image/*
  • application/pdf
  • application/json
  • application/octet-stream

Attempting to upload an executable or other unsupported type returns 415 Unsupported Media Type.

Storage limits and eviction

SettingDefaultEffect
max_file_bytesno limit413 Payload Too Large if exceeded
max_total_bytesno limitOldest file is deleted to make room

Eviction is FIFO: when a new upload would push the total over max_total_bytes, the oldest file (by created_at) is deleted first, then the next oldest, until there is room.

S3 caveat: quota accounting is best-effort under concurrent load — two simultaneous uploads might both pass the check and briefly exceed the limit. The next upload will evict back within bounds.

Deduplication

Coulisse computes a SHA-256 of each uploaded file. If you upload the same bytes twice, the second call returns the same file_id — no storage is consumed and no blob is written twice.

v1 limitation — deduplication is global, not per-user. If two different callers upload identical bytes, they receive the same file_id and share the underlying blob. A DELETE by either caller removes the file for both. This is safe when Coulisse runs as a single-tenant tool (one team, one trusted process). Do not expose Coulisse to mutually untrusted users until per-user deduplication is implemented (tracked in #61).

What Coulisse does NOT do

Coulisse does not parse, extract, or summarise file content. It stores the bytes and forwards them to the LLM backend. If the model supports the file type (e.g. GPT-4o reads PDFs natively), it will process it. If it does not, the request fails at the LLM level — Coulisse surfaces the error as-is.

If you want structured extraction (e.g. parse a CV into memory facts), that is a pattern you implement with a Coulisse agent that calls memory.put — see the per-user memory chapter.