Skip to content
Rigel docs
Open Rigel

Files

Files are uploaded as raw bytes, stored once for the household, and linked to records by their ID.

What a file is#

Everything that holds bytes in Rigel, a photo, a PDF, a video, an email attachment, a generated icon, holds them as one household file. Other records never keep bytes. They keep a file_id, which looks like fil_01JC9B7E1ZQWDZ74AY0FMN755Q.

A file has up to two copies, and at least one.

  • The original is exactly the bytes that were uploaded, of any accepted type.
  • The display copy is an image that stands for the file on screen, made by the client that uploads. For a photo it is the picture drawn upright, at most 2048 pixels on its longest side, which also drops the metadata, location included. For a video it is a poster frame, and for a PDF its first page when the client can draw one. A file with nothing to show has none.

A copy is never replaced. A file can gain the copy it is missing, and that is the only change a file ever gets. Who stored a file and when is in the household's change history, as for every record.

The query files.file.get answers { "file": … } with a file's details, and { "file": null } for a file that doesn't exist. The upload routes return the details object itself, without the wrapper.

Get a file's details

Request, curl
curl 'https://rigel.house/api/v1/households/hh_01M6V9CS6GGVFCY7NAS8ZTQV95/queries/files.file.get?file_id=fil_01M6V9CS96RRJQEBVXVCKMMR0T' \
  -H "Cookie: rigel_session=$RIGEL_SESSION"
Request, TypeScript
const response = await fetch("https://rigel.house/api/v1/households/hh_01M6V9CS6GGVFCY7NAS8ZTQV95/queries/files.file.get?file_id=fil_01M6V9CS96RRJQEBVXVCKMMR0T", {
  headers: {
    Cookie: `rigel_session=${process.env.RIGEL_SESSION}`,
  },
});
const data = await response.json();
Request, HTTP
GET /api/v1/households/hh_01M6V9CS6GGVFCY7NAS8ZTQV95/queries/files.file.get?file_id=fil_01M6V9CS96RRJQEBVXVCKMMR0T HTTP/1.1
Host: rigel.house
Cookie: rigel_session=<your session>
Response, 200
HTTP/1.1 200 OK
Content-Type: application/json
X-Request-Id: req_01M6V9CS9RX30EMT6B4SRF0FT9

{
  "file": {
    "id": "fil_01M6V9CS96RRJQEBVXVCKMMR0T",
    "name": "pantry-middle-shelf.png",
    "kind": "image",
    "original": {
      "type": "image/png",
      "size": 655
    },
    "display": {
      "type": "image/png",
      "size": 342,
      "width": 96,
      "height": 64
    },
    "created_at": "2026-11-07T15:17:52.045Z"
  }
}

kind is one of image, video, audio, pdf or other, worked out from the original's type, or image when only a display copy exists. The routes are in the files area of the reference.

Uploading#

POST /api/v1/households/{household_id}/files creates a file from its first copy. The body is the raw bytes, not a form. Send the media type in Content-Type and the exact size in Content-Length. The query takes name (1 to 255 characters, no / or \ or control characters) and copy, which is original or display. A display copy also needs width and height in pixels.

Shell
curl -X POST "https://rigel.house/api/v1/households/hh_01JC8Q4W2N7XK5R3VYTZ9D6HBE/files?name=IMG_2231.jpg&copy=original" \
  -H "Cookie: rigel_session=$RIGEL_SESSION" \
  -H "Content-Type: image/jpeg" \
  -H "Idempotency-Key: cmd_01JC9B7E1ZK4M8XQ2NTV6D3RWH" \
  --data-binary @IMG_2231.jpg
TypeScript
import { readFile } from "node:fs/promises";

const bytes = await readFile("IMG_2231.jpg");
const response = await fetch(
  "https://rigel.house/api/v1/households/hh_01JC8Q4W2N7XK5R3VYTZ9D6HBE/files?name=IMG_2231.jpg&copy=original",
  {
    method: "POST",
    headers: {
      Cookie: `rigel_session=${process.env.RIGEL_SESSION}`,
      "Content-Type": "image/jpeg",
      "Idempotency-Key": "cmd_01JC9B7E1ZK4M8XQ2NTV6D3RWH",
    },
    body: bytes, // fetch sends Content-Length from the buffer
  },
);
const file = await response.json();
HTTP
HTTP/1.1 201 Created
Location: /api/v1/households/hh_01JC8Q4W2N7XK5R3VYTZ9D6HBE/queries/files.file.get?file_id=fil_01JC9B7E1ZQWDZ74AY0FMN755Q
Content-Type: application/json

{
  "id": "fil_01JC9B7E1ZQWDZ74AY0FMN755Q",
  "name": "IMG_2231.jpg",
  "kind": "image",
  "original": { "type": "image/jpeg", "size": 2381923 },
  "display": null,
  "created_at": "2026-11-09T16:00:00.000Z"
}

Retries. The Idempotency-Key is a command ID (cmd_…), and the file's ID is derived from it. A retry with the same key and the same name, copy, type and size returns the file the first try made. The same key with anything different is 409 idempotency_key_reused. Make a fresh key for every file, and reuse it only for retries of that upload.

The other copy. PUT /api/v1/households/{household_id}/files/{file_id}/{copy} adds the copy a file doesn't have yet, again with raw bytes, and width and height for a display copy. If the file already has that copy the answer is 409 file_copy_exists. A client that retries after a dropped connection treats that as success. The web app uploads a photo's display copy first and then its original, so the picture shows before the original has finished. A script that has no display copy to make uploads the original alone.

Accepted types#

Rigel checks the type you declare against the first 16 bytes of the file while it streams in, so a page can't be uploaded as a JPEG. A mismatch is 415 unsupported_file_type and nothing is kept.

KindTypes
imageimage/jpeg, image/png, image/gif, image/webp, image/heic, image/heif, image/avif
pdfapplication/pdf
videovideo/mp4, video/quicktime, video/webm
audioaudio/mpeg, audio/mp4, audio/webm, audio/ogg, audio/wav
otherany other well-formed media type, such as a Word document, a spreadsheet or plain text

Images, PDFs, video and audio are served to be shown in a page. Every other file is stored with the type you declared, but always served as application/octet-stream with Content-Disposition: attachment, so it downloads and never renders. SVG counts as other. A display copy is always image/jpeg, image/webp, or image/png, between 1 and 4096 pixels on each side.

Downloading#

GET /api/v1/households/{household_id}/files/{file_id}/content streams the display copy, or the original when there is no display copy. ?copy=original or ?copy=display picks one, and a copy the file doesn't have is 404. ?download=1 asks the browser to save the file instead of showing it. It needs the session cookie, so it works as an image or link target on rigel.house itself, and from a script that sends the cookie.

Shell
curl "https://rigel.house/api/v1/households/hh_01JC8Q4W2N7XK5R3VYTZ9D6HBE/files/fil_01JC9B7E1ZQWDZ74AY0FMN755Q/content?copy=original" \
  -H "Cookie: rigel_session=$RIGEL_SESSION" \
  -o IMG_2231.jpg
  • Ranges. A single Range: bytes=0-99 gets 206 with Content-Range. Safari needs this to play video. A range past the end is 416 range_not_satisfiable with Content-Range: bytes */size. A malformed range, or several at once, is ignored and the whole file is sent. HEAD returns the same headers.
  • Caching. Responses are Cache-Control: private, no-cache with an ETag for each copy. Send it back as If-None-Match and an unchanged copy answers 304. The default copy changes when a display copy arrives, and its ETag changes with it.
  • Safety headers. Every response has X-Content-Type-Options: nosniff and Cross-Origin-Resource-Policy: same-origin, which stops other sites from embedding the bytes. Everything except PDFs also carries a sandboxing Content-Security-Policy, so bytes opened directly can't run anything. Content-Disposition has the file's name.

Limits and errors#

LimitValue
Original95 MiB
Display copy10 MiB
Storage per household, both copies counted20 GiB
Uploads60 requests a minute per person, POST and PUT together
Empty filesrefused

A file over the limit needs multipart uploads, which aren't built. A refused upload keeps nothing. The errors use the usual envelope. Membership is checked first, then the rate limit, then the request.

StatusCodeWhen
400validation_failedA bad or missing name, copy, width, height or Idempotency-Key, an unknown query parameter, a Content-Length that isn't a number, or an empty body
400length_mismatchThe body wasn't exactly Content-Length bytes
404not_found, file_not_foundYou're not a member of the household, or the file doesn't exist
409file_copy_exists, storage_full, idempotency_key_reusedThe copy is already there, the household is out of space, or the key was used for something else
411length_requiredNo Content-Length
413file_too_largeOver the copy's limit, judged from Content-Length before anything is read
415unsupported_file_typeA malformed type, a display copy that isn't JPEG, WebP or PNG, or bytes that don't match the declared type
416range_not_satisfiableReading only: the range starts past the end
429rate_limitedToo many uploads. The answer has Retry-After

Retry network failures, 408, 429 (after the wait it asks for) and 5xx with the same Idempotency-Key. Every other error will answer the same way again.

Linking a file to records, and removing it#

Upload first, then run the command that takes the file's ID. The upload must have finished, because a command that names a file that isn't there is rejected with file_not_found. These commands take a file ID today, with the field to put it in:

AppCommands and their file fields
calendarcalendar.event.attach (file_id), calendar.event.detach (file_id)
chatchat.custom_emoji.create (file_id), chat.message.send (file_ids), chat.sticker.create (file_id)
dashboarddashboard.app_picture.set (file_id)
documentsdocuments.document.add_file (file_id)
emailemail.draft.write (file_ids)
inventoryinventory.change.apply (file_id, photo_file_id), inventory.item.add (photo_file_id), inventory.item.set_photo (file_id), inventory.product.set_icon (file_id)
maintenancemaintenance.asset.add (file_id), maintenance.asset.add_photo (file_id), maintenance.attribute.record (file_id), maintenance.identification.record (file_id), maintenance.invoice.record (file_id), maintenance.invoice.update (file_id), maintenance.issue.report (photo_file_id), maintenance.issue.update (photo_file_id), maintenance.part.add (file_id), maintenance.part.fit (file_id), maintenance.project.log (file_id), maintenance.task.add (file_id), maintenance.task.complete (photo_file_id), maintenance.task.update (file_id), maintenance.warranty.add (file_id), maintenance.warranty.update (file_id)
shoppingshopping.aisle.set (photo_file_id), shopping.aisle.update (photo_file_id), shopping.trip.record (receipt_file_id), shopping.trip.update (receipt_file_id)

A file that Rigel draws, such as a recipe's picture or a product's icon, is stored the same way, so it has a file ID and the same routes read it.

Rigel can look at a photo too. Attach its file ID to a message to Rigel and it reads the image. See talking to Rigel.

files.file.remove deletes a file for good. Records that can live without the file lose it: an icon that can be made again is cleared. Records that exist only to hold the file go with it. A record that needs the file, such as a chat message's attachment, makes the command fail with file_in_use, and details.used_by lists the tables, columns and row counts that still point at it. Rigel asks first before it removes a file.

Rigel is in early access, and sign-up is by invitation. Open Rigel