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
curl 'https://rigel.house/api/v1/households/hh_01M6V9CS6GGVFCY7NAS8ZTQV95/queries/files.file.get?file_id=fil_01M6V9CS96RRJQEBVXVCKMMR0T' \
-H "Cookie: rigel_session=$RIGEL_SESSION"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();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>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.
curl -X POST "https://rigel.house/api/v1/households/hh_01JC8Q4W2N7XK5R3VYTZ9D6HBE/files?name=IMG_2231.jpg©=original" \
-H "Cookie: rigel_session=$RIGEL_SESSION" \
-H "Content-Type: image/jpeg" \
-H "Idempotency-Key: cmd_01JC9B7E1ZK4M8XQ2NTV6D3RWH" \
--data-binary @IMG_2231.jpgimport { 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©=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/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.
| Kind | Types |
|---|---|
| image | image/jpeg, image/png, image/gif, image/webp, image/heic, image/heif, image/avif |
application/pdf | |
| video | video/mp4, video/quicktime, video/webm |
| audio | audio/mpeg, audio/mp4, audio/webm, audio/ogg, audio/wav |
| other | any 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.
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-99gets206withContent-Range. Safari needs this to play video. A range past the end is416 range_not_satisfiablewithContent-Range: bytes */size. A malformed range, or several at once, is ignored and the whole file is sent.HEADreturns the same headers. - Caching. Responses are
Cache-Control: private, no-cachewith anETagfor each copy. Send it back asIf-None-Matchand an unchanged copy answers304. The default copy changes when a display copy arrives, and its ETag changes with it. - Safety headers. Every response has
X-Content-Type-Options: nosniffandCross-Origin-Resource-Policy: same-origin, which stops other sites from embedding the bytes. Everything except PDFs also carries a sandboxingContent-Security-Policy, so bytes opened directly can't run anything.Content-Dispositionhas the file's name.
Limits and errors#
| Limit | Value |
|---|---|
| Original | 95 MiB |
| Display copy | 10 MiB |
| Storage per household, both copies counted | 20 GiB |
| Uploads | 60 requests a minute per person, POST and PUT together |
| Empty files | refused |
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.
| Status | Code | When |
|---|---|---|
| 400 | validation_failed | A 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 |
| 400 | length_mismatch | The body wasn't exactly Content-Length bytes |
| 404 | not_found, file_not_found | You're not a member of the household, or the file doesn't exist |
| 409 | file_copy_exists, storage_full, idempotency_key_reused | The copy is already there, the household is out of space, or the key was used for something else |
| 411 | length_required | No Content-Length |
| 413 | file_too_large | Over the copy's limit, judged from Content-Length before anything is read |
| 415 | unsupported_file_type | A malformed type, a display copy that isn't JPEG, WebP or PNG, or bytes that don't match the declared type |
| 416 | range_not_satisfiable | Reading only: the range starts past the end |
| 429 | rate_limited | Too 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:
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.