CLEMA protocol

Files

CLEMA has two deliberately separate file models:

  1. Stored attachment — uploaded to the configured object store, attached to a durable message and available from history on every device.
  2. Direct transfer — an online-only, one-shot relay whose bytes are never stored. History keeps only a record of the offer and how it ended.

Stored Attachment Flow

Sender                         Server / object store               Recipient
  │── file.create ───────────────>│                                    │
  │<── file_id, part_size, parts ─│                                    │
  │── PUT /files/{id}/parts/1 ───>│                                    │
  │── file.get (resume) ─────────>│                                    │
  │── file.complete ─────────────>│                                    │
  │── message.send {file_id} ────>│── message.created ────────────────>│
  │                               │── media processing                 │
  │<── file.updated (if attached) ┼── file.updated ───────────────────>│
  │                               │<── GET /files/{file_id} ───────────│

The upload and message are separate operations. file.complete publishes the object as ready, then message.send atomically claims it. Until that final command succeeds, the file is absent from history.

Stored bytes are kept once, however many attachments show them: a file and its forwards share them, and so do two uploads of the same bytes once the second is found to be the first's. They are kept while any attachment shows them, and deleted a day after the last one goes.

Create or Resume Upload

{
  "id": "50",
  "type": "file.create",
  "data": {
    "operation_id": "0195d1a0-upload-photo",
    "name": "photo.jpg",
    "size": 2048000,
    "content_type": "image/jpeg"
  }
}
{
  "id": "50",
  "type": "file.create",
  "ok": true,
  "data": {
    "file_id": "4dbf74d3bb384ca0a86b29479a2a45ea",
    "name": "photo.jpg",
    "size": 2048000,
    "content_type": "image/jpeg",
    "status": "pending",
    "part_size": 8388608,
    "parts": [],
    "expires_in": 86400
  }
}

operation_id is idempotent per owner. Retrying file.create with the same ID, name and size returns the original file_id and its current uploaded parts, or the file as it is once complete; reusing it with another name or size returns conflict. The content type is not compared: it is the client's guess until file.complete replaces it with what the bytes are, and a retry that lost the answer to file.complete still sends its guess. To refresh an already known upload explicitly, use:

{"id":"51","type":"file.get","data":{"file_id":"4dbf74d3bb384ca0a86b29479a2a45ea"}}

The response has the same shape. Each item in parts is {"part":1,"etag":"...","size":2048000}. Only the owner can inspect an unattached upload.

Upload Part

PUT /files/{file_id}/parts/{part_number}
Authorization: Bearer <access_token>
Content-Type: application/octet-stream
Content-Length: <exact part size>

[binary part]

Parts are numbered from 1. Every non-final part must equal part_size; the final part is the exact remainder. part_size is normally the configured default and grows automatically for very large files to stay within the S3 limit of 10,000 parts. A successful response is:

HTTP/1.1 200 OK
ETag: "<etag>"
Content-Type: application/json

{"etag":"<etag>"}

Uploading the same part number again replaces it, which makes an interrupted part retry safe.

Complete Upload

{
  "id": "52",
  "type": "file.complete",
  "data": {
    "file_id": "4dbf74d3bb384ca0a86b29479a2a45ea"
  }
}
{
  "id": "52",
  "type": "file.complete",
  "ok": true,
  "data": {
    "file": {
      "file_id": "4dbf74d3bb384ca0a86b29479a2a45ea",
      "name": "photo.jpg",
      "size": 2048000,
      "content_type": "image/jpeg",
      "available": true,
      "media": {
        "kind": "image",
        "state": "pending"
      }
    }
  }
}

The server reads the uploaded part list from object storage, then verifies part numbers, sizes, ETags and the final object size before marking it ready. It also sniffs the stored bytes and replaces the client-declared content_type with the detected value. The client does not echo the potentially large part list over WebSocket. Repeating completion of an already ready or attached file is safe and returns its current metadata.

Once stored, the bytes are read in the background for their SHA-256, which the message's snapshots then give as the file's hash, in lowercase hex, as does any later file.updated; it is empty until then, and for files over 2 GiB, which are not read for it. Bytes found to be stored already are kept once: the file shows the stored copy from then on, and its media is that copy's. Media inspection follows hashing.

For detected images, video and audio, media.state is initially pending (or processing). A single bounded background worker inspects the actual streams, refines ambiguous containers such as MP4, WebM and Ogg to audio or video, and extracts dimensions and duration_ms; images and video also receive a JPEG preview whose longest edge is at most 960 pixels. A HEIC, HEIF or AVIF photo is measured and previewed as the whole picture, upright, rather than as the tiles its container cuts it into. Processing input is bounded at 2 GiB and generated previews at 8 MiB. The original object is immutable and remains downloadable if inspection fails or a file exceeds the processing bound. In that case the final state is failed and no derivative is required. Media processing is optional at deployment time; without a processor detected media starts as failed. The bundled image uses FFmpeg/FFprobe, and libheif for HEIF images.

The public descriptor is:

Field Description
kind image, video or audio, derived from bytes
state pending, processing, ready or failed
width, height Original visual dimensions when known
duration_ms Audio/video duration in milliseconds when known
preview Ready derivative descriptor: variant, type, dimensions and size

No new message kinds are introduced: media remains a file message and clients that do not understand the optional descriptor keep their normal file UI.

Media Updated

If processing finishes after the file has been attached, every conversation member receives file.updated, an event of the conversation stream; a member who hid the message is only told its file_id:

{
  "type": "file.updated",
  "conversation_id": "direct:12345:67890",
  "seq": "44",
  "data": {
    "file": {
      "file_id": "4dbf74d3bb384ca0a86b29479a2a45ea",
      "name": "photo.jpg",
      "size": 2048000,
      "content_type": "image/jpeg",
      "available": true,
      "media": {
        "kind": "image",
        "state": "ready",
        "width": 1920,
        "height": 1080,
        "preview": {
          "variant": "preview",
          "content_type": "image/jpeg",
          "width": 960,
          "height": 540,
          "size": 84217
        }
      }
    }
  }
}

Clients replace the payload of the message carrying that file_id. If processing wins the race with message.send, no update is necessary and the created message already contains the final descriptor.

Abort Upload

{"id":"53","type":"file.abort","data":{"file_id":"4dbf74d3bb384ca0a86b29479a2a45ea"}}

This removes a pending multipart upload or a completed-but-unattached object. An attached file cannot be aborted; delete its message for everyone instead.

Download Stored File

GET /files/{file_id}
Authorization: Bearer <access_token>
Range: bytes=0-1048575

The sender and recipient may download while the message remains visible to them. An unrelated or locally hidden user receives 404, avoiding resource disclosure. Responses include Content-Disposition, Content-Length, Accept-Ranges: bytes, ETag when available and 206 Partial Content with Content-Range for a range request. An unsatisfiable range returns 416 / invalid_format.

Inline Media and Preview Access

Browser media elements cannot attach a Bearer header to their follow-up Range requests. A visible conversation member obtains a short-lived scoped ticket:

POST /files/{file_id}/access
Authorization: Bearer <access_token>
{
  "content_url": "/files/{file_id}/content?ticket=...",
  "preview_url": "/files/{file_id}/variants/preview?ticket=...",
  "expires_at": "2026-08-05T12:30:00Z"
}

preview_url is omitted until a preview is ready and for audio. URLs are server-relative, expire after 30 minutes and are bound to exactly one active authentication session, user and file_id. Each GET rechecks both the session and current message visibility, so logout, remote session revocation, deletion, history hiding or conversation removal immediately removes access. The content endpoint serves only byte-detected passive media with inline content disposition, X-Content-Type-Options: nosniff and Range support. The variant endpoint inherits the same authorization and lifecycle. A regular Bearer header is also accepted by both GET endpoints for non-browser clients.

The ticket is a narrow playback capability, not a persistent download URL. A client requests another ticket after expiry or a media load failure. Generic files and active markup never receive inline access and continue through the attachment download endpoint above.

Abandoned Uploads and Deletion

Each successful part refreshes upload activity. Pending uploads and ready files that were never attached are removed after MANDARIN_FILE_UPLOAD_TTL (24 hours by default). Sebastian independently cleans its multipart staging with the same activity-based TTL. A global message deletion marks the attachment for background object deletion. A one-sided hide retains bytes for the other participant; once both participants hide the message or clear that history, the attachment and every derivative are collected as well.

Direct Transfer

A direct transfer requires the recipient to have a client that handles transfers within reach: a connection, passive or not, whose connection.hello declared transfer.created, or a device registered for pushes whose client declared it when it connected last. Otherwise transfer.create returns invalid_state. The sender must be at their client: a passive connection cannot offer a file. The relay is memory-only and one-shot; what history keeps of it is its record.

Create Offer

{
  "id": "60",
  "type": "transfer.create",
  "data": {
    "conversation_id": "direct:12345:67890",
    "name": "temporary.zip",
    "size": 7340032,
    "content_type": "application/zip"
  }
}

A transfer goes between the two participants of a direct conversation; another kind of conversation returns invalid_state. The sender response contains transfer_id, msg_id (the transfer's record), conversation_id, from, to, file metadata, token and expires_in. The recipient receives the same metadata without the token, after the record's message.created:

{
  "type": "transfer.created",
  "data": {
    "transfer_id": "66ca36b6a2904e59a55d96c2155a9cf1",
    "msg_id": "12420",
    "conversation_id": "direct:12345:67890",
    "from": 67890,
    "to": 12345,
    "name": "temporary.zip",
    "size": 7340032,
    "content_type": "application/zip",
    "expires_in": 300
  }
}

Accept, Decline or Cancel

The recipient accepts with:

{"id":"61","type":"transfer.accept","data":{"transfer_id":"66ca36b6a2904e59a55d96c2155a9cf1"}}

The response contains {"token":"..."}. The sender receives transfer.accepted with transfer_id and user_id, the recipient's. A passive connection may accept too: the person answered a notification.

The recipient may instead send transfer.decline; either party may send transfer.cancel. Both use:

{
  "id": "62",
  "type": "transfer.cancel",
  "data": {
    "transfer_id": "66ca36b6a2904e59a55d96c2155a9cf1",
    "reason": "optional text"
  }
}

The other party receives transfer.declined or transfer.cancelled with transfer_id, user_id (who declined or cancelled) and optional reason, and so do the actor's other clients, any of which may hold the offer or the file. A transfer that ends without either party's word is reported to both as transfer.cancelled with a reason and no user_id: expired, failed for a relay that broke off, unavailable for a block and withdrawn when its sender deleted its record for everybody.

Direct HTTP Relay

After acceptance, the sender and recipient use the same capability token but must also authenticate in their respective roles:

POST /transfers/{token}          GET /transfers/{token}
Authorization: Bearer <sender>  Authorization: Bearer <recipient>
Content-Length: <offered size>

The server streams the POST body directly into the GET response and stores no bytes. The declared size must match exactly. A second concurrent downloader is rejected with 409 conflict. Missing, expired and unauthorized sessions use the structured HTTP errors described above. Offers expire after 5 minutes; accepted transfers have a 10-minute idle timeout.

Transfer Record

An offer is also written into the direct conversation as a message of kind transfer, from the sender, with the next message.created. Nobody sends one — message.send refuses the kind — and it cannot be edited or forwarded; it can be replied to, reacted to, pinned and deleted like any message. For the recipient it is unread like any message and wakes those of their devices whose client handles transfers, with the file name as its preview.

{
  "msg_id": "12420",
  "conversation_id": "direct:12345:67890",
  "from": 67890,
  "kind": "transfer",
  "payload": {
    "transfer_id": "66ca36b6a2904e59a55d96c2155a9cf1",
    "name": "temporary.zip",
    "size": 7340032,
    "content_type": "application/zip",
    "state": "offered"
  },
  "time": "2024-01-15T14:40:00Z",
  "revision": 1,
  "reactions": []
}

state says how far the transfer got. Each move is a message.edited with the next revision and no edited_at, and a record only moves up the table — from offered to accepted to one of the states that end it — so word of a transfer that comes late, such as an acceptance recorded after the bytes went across, changes nothing:

State Meaning
offered Waiting for the recipient
accepted Accepted; the bytes are under way
completed The relay carried the whole file
declined The recipient declined
cancelled A party cancelled, a block ended it, or its sender withdrew it
expired Not answered in time, or accepted and never carried
failed Broke off on the way, or its taker fell behind

While the record says offered, any of the recipient's clients may accept or decline by its transfer_id, and either party may cancel while it says offered or accepted. Deleting the record for everybody withdraws an offer still open. Transfers live in the server's memory: one still open when the server stops ends at its next start, an offer as expired and an accepted transfer as failed, and a record that says a transfer is open that nothing relays is ended the same way when a party acts on it, which answers transfer_not_found. A record deleted for everybody keeps no state anybody sees.

transfer.create answers with the transfer rather than the record, so the record's message.created reaches every client of both parties, the one that offered included; the record's later moves likewise reach all of them.

File Security