Queries and lists
A query is a read with named inputs: a GET with flat query parameters that answers with JSON.
Calling a query#
A query is named <app>.<record>.<verb>, like a command: family.person.list, inventory.item.expiring, recipes.recipe.search. Its route is a GET on the name, with the inputs as query parameters and no body.
GET /api/v1/households/{household_id}/queries/{name}?param=valueA query answers 200 with the data itself. There is no result wrapper and no household version, unlike a command. A query can't change anything. It is computed when you ask, in the household's time zone, so "today" and "this week" mean the family's.
Everything Rigel knows about the family
curl 'https://rigel.house/api/v1/households/hh_01M6V9CS6GGVFCY7NAS8ZTQV95/queries/family.knowledge.get' \
-H "Cookie: rigel_session=$RIGEL_SESSION"const response = await fetch("https://rigel.house/api/v1/households/hh_01M6V9CS6GGVFCY7NAS8ZTQV95/queries/family.knowledge.get", {
headers: {
Cookie: `rigel_session=${process.env.RIGEL_SESSION}`,
},
});
const data = await response.json();GET /api/v1/households/hh_01M6V9CS6GGVFCY7NAS8ZTQV95/queries/family.knowledge.get HTTP/1.1
Host: rigel.house
Cookie: rigel_session=<your session>HTTP/1.1 200 OK
Content-Type: application/json
X-Request-Id: req_01M6V9CS7SQ4S1P84SQZWMS553
{
"everyone": [
{
"id": "fnt_01M6V9CS7MX4PM9SC6Z6NVA2EA",
"text": "Saturday mornings are for pancakes.",
"app": null,
"noted_at": "2026-11-07T15:17:51.989Z",
"noted_ago": "just now"
},
{
"id": "fnt_01M6V9CS7KC15H3QT502N0J25K",
"text": "Dinner is at 6:00 pm on weeknights.",
"app": null,
"noted_at": "2026-11-07T15:17:51.988Z",
"noted_ago": "just now"
}
],
"people": [
{
"id": "per_01M6V9CS6RX26JK5KVYGRN22D2",
"name": "Ada Okafor",
"age": null,
"signs_in": true,
"notes": []
},
{
"id": "per_01M6V9CS744BQQH188FS6CXY9W",
"name": "Ben Okafor",
"age": null,
"signs_in": false,
"notes": [
{
"id": "fnt_01M6V9CS7HSE0X6FSYVAC690KF",
"text": "Ben doesn't eat shellfish.",
"app": "meals",
"noted_at": "2026-11-07T15:17:51.986Z",
"noted_ago": "just now"
}
]
},
{
"id": "per_01M6V9CS79TMYHDAM7FTA9ZK3Z",
"name": "Nia",
"age": "8 years old",
"signs_in": false,
"notes": [
{
"id": "fnt_01M6V9CS7GXFHGYGZ4CFZG4J38",
"text": "Nia wants to learn to bake. She likes measuring the flour.",
"app": "recipes",
"noted_at": "2026-11-07T15:17:51.985Z",
"noted_ago": "just now"
}
]
},
{
"id": "per_01M6V9CS7CDAEP5VDA5T8R9TG6",
"name": "Theo",
"age": "5 years old",
"signs_in": false,
"notes": [
{
"id": "fnt_01M6V9CS7E9NAJKGNWG7KBC8P4",
"text": "Theo isn't into spicy food lately. Mild chili is fine.",
"app": "meals",
"noted_at": "2026-11-07T15:17:51.990Z",
"noted_ago": "just now"
}
]
}
]
}Each query's reference page lists its parameters, with which are required, their limits and their defaults, and the shape of its answer. Queries that read shared household data are also what Rigel uses to look things up.
Parameters#
Parameters are flat and use snake_case names. Send them as plain text.
| Kind | How to send it |
|---|---|
| Text and IDs | As is, URL-encoded: text=oat%20milk. |
| Numbers | Digits: limit=50. |
| True or false | true or false: staples_only=true. |
| Dates | YYYY-MM-DD. |
| Lists | Repeat the parameter: product_ids=prd_01…&product_ids=prd_01…. Not brackets, and not commas, unless the parameter's description says it takes a single value or a comma-separated list. |
| Left out | The default, when the parameter is optional. |
A parameter that doesn't fit is refused with 400 validation_failed, and details.fields names it. A parameter the query doesn't define is ignored today, so check the spelling against the reference page.
Lists and cursors#
A query that can return many records is a paged list. It answers with an envelope:
{
"items": [
{ "id": "inv_01JC4M9X4A1C3E5G7J9M2P4R6T", "product": { "name": "black beans" }, "amount": "2 cans" },
{ "id": "inv_01JC4M9X4B7D9F1H3K5N8Q0S2V", "product": { "name": "oat milk" }, "amount": "1 carton" }
],
"next_cursor": "WyJvYXQgbWlsayIsImludl8wMUpDNE05WDRCN0Q5RjFIM0s1TjhRMFMyViJd"
}itemsis the page, in the order the query documents. An empty list is{ "items": [], "next_cursor": null }.next_cursoris a string while there is more, andnullon the last page.limitis how many to return. It is usually 1 to 200 with a default of 50, and a query's page says when its range differs.
To get the next page, send the same request with cursor set to the last next_cursor. Keep every other parameter the same. A cursor only makes sense with the filters it came from. Treat it as opaque: don't build one, read one or keep one for later.
curl -sS "https://rigel.house/api/v1/households/$HOUSEHOLD/queries/inventory.item.list?limit=2" \
-H "Cookie: rigel_session=$RIGEL_SESSION"
# Copy next_cursor from the first response:
export NEXT_CURSOR=WyJvYXQgbWlsayIsImludl8wMUpDNE05WDRCN0Q5RjFIM0s1TjhRMFMyViJd
curl -sS "https://rigel.house/api/v1/households/$HOUSEHOLD/queries/inventory.item.list?limit=2&cursor=$NEXT_CURSOR" \
-H "Cookie: rigel_session=$RIGEL_SESSION"async function* allItems(household: string): AsyncGenerator<{ id: string }> {
let cursor: string | null = null;
do {
const params = new URLSearchParams({ limit: "100" });
if (cursor !== null) params.set("cursor", cursor);
const response = await fetch(
`https://rigel.house/api/v1/households/${household}/queries/inventory.item.list?${params}`,
{ headers: { Cookie: `rigel_session=${process.env.RIGEL_SESSION}` } },
);
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
const page = (await response.json()) as { items: { id: string }[]; next_cursor: string | null };
yield* page.items;
cursor = page.next_cursor;
} while (cursor !== null);
}List what we have
curl 'https://rigel.house/api/v1/households/hh_01M6V9CS6GGVFCY7NAS8ZTQV95/queries/inventory.item.list' \
-H "Cookie: rigel_session=$RIGEL_SESSION"const response = await fetch("https://rigel.house/api/v1/households/hh_01M6V9CS6GGVFCY7NAS8ZTQV95/queries/inventory.item.list", {
headers: {
Cookie: `rigel_session=${process.env.RIGEL_SESSION}`,
},
});
const data = await response.json();GET /api/v1/households/hh_01M6V9CS6GGVFCY7NAS8ZTQV95/queries/inventory.item.list HTTP/1.1
Host: rigel.house
Cookie: rigel_session=<your session>HTTP/1.1 200 OK
Content-Type: application/json
X-Request-Id: req_01M6V9CSBHT82QB5P7MF4WX9WR
{
"items": [
{
"id": "inv_01M6V9CSAPM8JH7W1W1YEBHPRS",
"product": {
"id": "prd_01M6V9CSAMSN9ZJ52PTTKT8G6M",
"name": "Black beans",
"category": "Canned goods",
"is_staple": false,
"is_tracked": true
},
"location": {
"id": "loc_01M6V9CSA910340EZNX67RFMV0",
"name": "Middle shelf",
"path": "Kitchen pantry › Middle shelf",
"environment": "room"
},
"brand": null,
"variant": null,
"size": null,
"amount": "3 cans",
"quantity": 3,
"quantity_unit": "can",
"level": null,
"acquired_on": null,
"opened_on": null,
"label_best_by": null,
"expires_on": null,
"expires_source": null,
"expiry": null,
"days_left": null,
"confirmed_at": "2026-11-07T15:17:52.086Z",
"seen_ago": "just now",
"seen_on": "Nov 7",
"picture": null,
"note": null
},
{
"id": "inv_01M6V9CSB2QRQDPSG49EFX353W",
"product": {
"id": "prd_01M6V9CSB2GBRX2QG46DDBK4TW",
"name": "Chicken thighs",
"category": null,
"is_staple": false,
"is_tracked": true
},
"location": {
"id": "loc_01M6V9CSABVCCJRZ4RWVFW77D6",
"name": "Chest freezer",
"path": "Chest freezer",
"environment": "frozen"
},
"brand": null,
"variant": null,
"size": null,
"amount": "2 lb",
"quantity": 2,
"quantity_unit": "lb",
"level": null,
"acquired_on": "2026-10-18",
"opened_on": null,
"label_best_by": null,
"expires_on": null,
"expires_source": null,
"expiry": null,
"days_left": null,
"confirmed_at": "2026-11-07T15:17:52.099Z",
"seen_ago": "just now",
"seen_on": "Nov 7",
"picture": null,
"note": null
},
{
"id": "inv_01M6V9CSAJYYMNC0TXJX8NRN8T",
"product": {
"id": "prd_01M6V9CSAG72MC7ERJ4GCZ0X7A",
"name": "Flour",
"category": "Baking",
"is_staple": true,
"is_tracked": true
},
"location": {
"id": "loc_01M6V9CSA910340EZNX67RFMV0",
"name": "Middle shelf",
"path": "Kitchen pantry › Middle shelf",
"environment": "room"
},
"brand": "Mill Road",
"variant": null,
"size": "2 kg each",
"amount": "1 bag",
"quantity": 1,
"quantity_unit": "bag",
"level": null,
"acquired_on": "2026-10-29",
"opened_on": null,
"label_best_by": "2027-07-05",
"expires_on": null,
"expires_source": null,
"expiry": null,
"days_left": null,
"confirmed_at": "2026-11-07T15:17:52.083Z",
"seen_ago": "just now",
"seen_on": "Nov 7",
"picture": null,
"note": null
},
{
"id": "inv_01M6V9CSAWTFE7ARBQF4CXQ31Q",
"product": {
"id": "prd_01M6V9CSAWSAMCWTBQ2M7F80H9",
"name": "Milk",
"category": "Dairy",
"is_staple": true,
"is_tracked": true
},
"location": {
"id": "loc_01M6V9CSAACDPHW4NW889QQ2VV",
"name": "Kitchen fridge",
"path": "Kitchen fridge",
"environment": "cold"
},
"brand": null,
"variant": null,
"size": null,
"amount": "1 gallon",
"quantity": 1,
"quantity_unit": "gallon",
"level": null,
"acquired_on": null,
"opened_on": null,
"label_best_by": null,
"expires_on": "2026-11-10",
"expires_source": "label",
"expiry": "Use by Nov 10",
"days_left": 3,
"confirmed_at": "2026-11-07T15:17:52.093Z",
"seen_ago": "just now",
"seen_on": "Nov 7",
"picture": null,
"note": null
},
{
"id": "inv_01M6V9CSATNBB6KZZ14ZN1FE15",
"product": {
"id": "prd_01M6V9CSAT6M58YWZ1H4DFY9K2",
"name": "Olive oil",
"category": null,
"is_staple": false,
"is_tracked": true
},
"location": {
"id": "loc_01M6V9CSA910340EZNX67RFMV0",
"name": "Middle shelf",
"path": "Kitchen pantry › Middle shelf",
"environment": "room"
},
"brand": null,
"variant": null,
"size": null,
"amount": "mostly full",
"quantity": null,
"quantity_unit": null,
"level": "most",
"acquired_on": null,
"opened_on": null,
"label_best_by": null,
"expires_on": null,
"expires_source": null,
"expiry": null,
"days_left": null,
"confirmed_at": "2026-11-07T15:17:52.091Z",
"seen_ago": "just now",
"seen_on": "Nov 7",
"picture": null,
"note": null
},
{
"id": "inv_01M6V9CSAQY5ZPFNNXYTWXW5CF",
"product": {
"id": "prd_01M6V9CSAQDSZHDQNXHXRCDA8Z",
"name": "Orzo",
"category": null,
"is_staple": false,
"is_tracked": true
},
"location": {
"id": "loc_01M6V9CSA910340EZNX67RFMV0",
"name": "Middle shelf",
"path": "Kitchen pantry › Middle shelf",
"environment": "room"
},
"brand": null,
"variant": null,
"size": null,
"amount": "1 box",
"quantity": 1,
"quantity_unit": "box",
"level": null,
"acquired_on": null,
"opened_on": null,
"label_best_by": null,
"expires_on": null,
"expires_source": null,
"expiry": null,
"days_left": null,
"confirmed_at": "2026-11-07T15:17:52.088Z",
"seen_ago": "just now",
"seen_on": "Nov 7",
"picture": null,
"note": null
},
{
"id": "inv_01M6V9CSB441KYPTJPNZGNVY4A",
"product": {
"id": "prd_01M6V9CSB4BEJEAXJPR0K7CZD9",
"name": "Paper towels",
"category": null,
"is_staple": false,
"is_tracked": true
},
"location": {
"id": "loc_01M6V9CSADPBG11HW38FMRV1ZQ",
"name": "Basement closet",
"path": "Basement closet",
"environment": "room"
},
"brand": null,
"variant": null,
"size": null,
"amount": "6 rolls",
"quantity": 6,
"quantity_unit": "roll",
"level": null,
"acquired_on": null,
"opened_on": null,
"label_best_by": null,
"expires_on": null,
"expires_source": null,
"expiry": null,
"days_left": null,
"confirmed_at": "2026-11-07T15:17:52.101Z",
"seen_ago": "just now",
"seen_on": "Nov 7",
"picture": null,
"note": null
},
{
"id": "inv_01M6V9CSB6S923R566WD7EAGGG",
"product": {
"id": "prd_01M6V9CSB6TEWX53669YBWSK0Y",
"name": "Salsa",
"category": null,
"is_staple": false,
"is_tracked": true
},
"location": {
"id": "loc_01M6V9CSAACDPHW4NW889QQ2VV",
"name": "Kitchen fridge",
"path": "Kitchen fridge",
"environment": "cold"
},
"brand": null,
"variant": null,
"size": null,
"amount": "1 jar",
"quantity": 1,
"quantity_unit": "jar",
"level": null,
"acquired_on": null,
"opened_on": "2026-11-05",
"label_best_by": null,
"expires_on": null,
"expires_source": null,
"expiry": null,
"days_left": null,
"confirmed_at": "2026-11-07T15:17:52.103Z",
"seen_ago": "just now",
"seen_on": "Nov 7",
"picture": null,
"note": null
},
{
"id": "inv_01M6V9CSB0YB2Y7WBZBMR3G5Z0",
"product": {
"id": "prd_01M6V9CSB0DQFW5YBZYYMJ0A77",
"name": "Yogurt",
"category": null,
"is_staple": false,
"is_tracked": true
},
"location": {
"id": "loc_01M6V9CSAACDPHW4NW889QQ2VV",
"name": "Kitchen fridge",
"path": "Kitchen fridge",
"environment": "cold"
},
"brand": null,
"variant": null,
"size": null,
"amount": "about half",
"quantity": null,
"quantity_unit": null,
"level": "half",
"acquired_on": null,
"opened_on": null,
"label_best_by": null,
"expires_on": "2026-11-13",
"expires_source": "label",
"expiry": "Use by Nov 13",
"days_left": 6,
"confirmed_at": "2026-11-07T15:17:52.096Z",
"seen_ago": "just now",
"seen_on": "Nov 7",
"picture": null,
"note": null
}
],
"next_cursor": null
}Some answers differ from the envelope. A small list that is always complete, such as family.person.list, answers with items alone. Some add fields beside items, such as the unread_count in notifications.notification.list. chat.message.list pages back in time, so its next_cursor gets the page before, and you send it as before, not cursor. The long lists in Maintenance take a limit and answer { items, truncated }, with truncated true when there were more than the limit. They have no cursor, so narrow the question instead. A few lookups, such as inventory.product.search, take a small limit and return only that many.
Limits and cursors are checked#
Nothing is clamped or quietly fixed. A limit of 0, 500 or abc is 400 validation_failed, and so is a cursor that didn't come from an earlier answer. details.fields names the parameter and says what is wrong.
{
"error": {
"code": "validation_failed",
"message": "The request has invalid fields. No changes were applied.",
"details": {
"fields": [{ "field": "limit", "problem": "Too big: expected number to be <=200" }]
},
"recovery": "Correct each field listed in details.fields, then send the request again.",
"request_id": "req_01JC4N9E2V5X8Z1B4D7F0H3K6N"
}
}A bad cursor reports cursor with "must be a next_cursor from an earlier answer". See validation errors.
Queries can be refused too#
A query can refuse a question the household's data can't answer, such as asking for the messages in a conversation that isn't there. It uses the same statuses as a rejected command: a code ending in _not_found is 404, such as conversation_not_found, any other state conflict is 409, and a role that can't ask is 403. A message says what was wrong, and recovery says what to do. Nothing changes, because a query never does.
A few queries are about the person asking rather than the household. notifications.notification.list returns the signed-in person's notifications, and notifications.mute.list returns the apps they muted.
When to read a local copy instead#
A query asks the server every time. That is right for a script, for a one-off lookup and for answers only the server has. inventory.item.history, for example, reads the audit trail, which devices don't hold.
An app that shows household data on screen doesn't poll queries. It keeps a copy of the household on the device, reads that copy, and updates it when a command runs or another device's change arrives. If you are building a client like that, use sync, not queries. The sync guide covers how to pull and push.