API reference

Housekeeping

Quiet Hours. The guest's room preference, the floor board, presence-aware attendant routes, and the serviced and do-not-disturb writes.

Quiet Hours is the housekeeping family. The guest tells the Concierge once how the room should be handled, and the core turns confirmed bookings and Concierge activity into room presence. An attendant's route re-sorts toward rooms that are verifiably empty, every move carries the line that explains it, and the guest gets a note once the room is done. Three guest routes set, read, and clear the preference. Four operator routes serve the floor board, one attendant's route, and the two writes an attendant makes.

Presence is room-grain. A room is occupied or verifiably empty, and the only place a venue name reaches an operator is the one window reason that names it. There is no per-attendant pace anywhere in the metrics. Everything in this family lives in core/src/quiet-hours.ts, which registers its own routes from server.ts in one line.

GET /v1/stays/:stay_id/service-prefs

Read the stay's room preference.

Auth: x-gi-session, matched against the stay_id in the path.

Request

ParamTypeRequiredMeaning
stay_id (path)stringrequiredThe stay whose preference is read. Must match the session token.

No query params, no body.

GET /v1/stays/stay-412/service-prefs

GET /v1/stays/stay-412/service-prefs
x-gi-session: 0f0b6f2c-4a1e-4a51-9a4a-2f4b8c1d33e7

Response

JSON

{
  "prefs": {
    "stay_id": "stay-412",
    "style": "never_knock",
    "towel_cadence": "every_other",
    "quiet_until": "10:00",
    "set_at": "2026-09-07T15:00:00Z"
  }
}

A stay that has set nothing returns { "prefs": null }. There is one row per stay, so this read never returns a list. The same object rides on the guest home payload as service_prefs.

Errors

StatusErrorWhen
401unauthorizedNo x-gi-session header, or the token does not match stay_id.

PUT /v1/stays/:stay_id/service-prefs

Set or replace the stay's room preference.

Auth: x-gi-session, matched against the stay_id in the path.

Request

ParamTypeRequiredMeaning
stay_id (path)stringrequiredThe stay the preference belongs to. Must match the session token.
FieldTypeRequiredMeaning
stylestringrequirednever_knock, mornings_fine, or skip_today. Anything else is rejected.
towel_cadencestringrequireddaily, every_other, or on_request.
quiet_untilstring or nulloptionalProperty-local HH:MM on a 24-hour clock, matched against ^([01]\d|2[0-3]):[0-5]\d$. An omitted value and the empty string both store null.

PUT /v1/stays/stay-412/service-prefs

PUT /v1/stays/stay-412/service-prefs
content-type: application/json
x-gi-session: 0f0b6f2c-4a1e-4a51-9a4a-2f4b8c1d33e7

{ "style": "never_knock", "towel_cadence": "every_other", "quiet_until": "10:00" }

Response

JSON

{
  "prefs": {
    "stay_id": "stay-412",
    "style": "never_knock",
    "towel_cadence": "every_other",
    "quiet_until": "10:00",
    "set_at": "2026-09-07T15:00:00Z"
  }
}

The write upserts on stay_id, so a second call replaces the row rather than adding one, and set_at moves to the new request instant. It emits service_pref_set carrying style, towel_cadence, and quiet_until, then re-sequences the route for the floor the stay's room sits on. A stay with no room on the board changes no route.

Errors

StatusErrorWhen
400invalid_prefsstyle or towel_cadence is outside its list, or quiet_until is not HH:MM.
401unauthorizedNo x-gi-session header, or the token does not match stay_id.

setServicePrefs also throws unknown_stay, which maps to a 404, but the session check runs first and an unknown stay_id fails it, so a caller sees 401 instead.

DELETE /v1/stays/:stay_id/service-prefs

Clear the stay's room preference. The preference is revocable, and this is how it is revoked.

Auth: x-gi-session, matched against the stay_id in the path.

Request

ParamTypeRequiredMeaning
stay_id (path)stringrequiredThe stay whose preference is cleared. Must match the session token.

No query params, no body.

DELETE /v1/stays/stay-412/service-prefs

DELETE /v1/stays/stay-412/service-prefs
x-gi-session: 0f0b6f2c-4a1e-4a51-9a4a-2f4b8c1d33e7

Response

JSON

{ "prefs": null }

The delete is idempotent. Clearing a stay that had nothing set returns the same body. It emits service_pref_set with params.cleared: true, which is what separates a clear from a set in the event log, and re-sequences the floor. Once the row is gone the board tile shows no pref_summary and the room falls back to an ordinary stop.

Errors

StatusErrorWhen
401unauthorizedNo x-gi-session header, or the token does not match stay_id.

GET /v1/housekeeping/board

The whole floor board, plus the attendant roster and the metric block, computed fresh on every call.

Auth: unauthenticated in this build.

Request

GET /v1/housekeeping/board

GET /v1/housekeeping/board

No path params, no query params, no body. x-gi-now pins the read instant, which is what moves a window's remaining_minutes and the trips_avoided count.

Response

Twenty-four rooms in room-number order. Three are shown here.

JSON

{
  "rooms": [
    {
      "room_number": "1201",
      "floor": 12,
      "state": "occupied",
      "guest_first_name": null,
      "pref_summary": null,
      "window": null,
      "presence_confidence": null,
      "attendant_id": null,
      "serviced_at": null,
      "reasons": []
    },
    {
      "room_number": "1202",
      "floor": 12,
      "state": "empty",
      "guest_first_name": null,
      "pref_summary": null,
      "window": {
        "venue": "Coral Grotto Pools",
        "opened_at": "2026-09-07T14:30:00Z",
        "est_minutes": 75,
        "remaining_minutes": 45
      },
      "presence_confidence": 0.9,
      "attendant_id": null,
      "serviced_at": null,
      "reasons": []
    },
    {
      "room_number": "1408",
      "floor": 14,
      "state": "occupied",
      "guest_first_name": "John",
      "pref_summary": "never knock · towels every other day · quiet until 10:00",
      "window": null,
      "presence_confidence": null,
      "attendant_id": null,
      "serviced_at": null,
      "reasons": [
        { "reason": "1408 held: pref never knock, guest in room", "occurred_at": "2026-09-07T15:00:00Z" }
      ]
    }
  ],
  "attendants": [
    { "attendant_id": "hk-rosa", "first_name": "Rosa", "floor": 12 },
    { "attendant_id": "hk-devon", "first_name": "Devon", "floor": 13 },
    { "attendant_id": "hk-amara", "first_name": "Amara", "floor": 14 }
  ],
  "metrics": {
    "knocks_avoided": 0,
    "trips_avoided": 1,
    "dnd_honored": 1,
    "rooms_turned_by_1pm_pct": 80,
    "false_empty_pct": 0
  }
}

rooms

The fixture floor is 24 rooms across floors 12, 13, and 14, eight to a floor, one attendant per floor.

state is one of occupied, empty, servicing, done, or dnd.

guest_first_name is present only when the room has a stay and that stay recorded analytics_consent: 1. Every other room is a number to the board and nothing more. Last names never appear.

pref_summary is the preference rendered as one line, style then towel cadence then the quiet time when there is one, joined with a middle dot. never knock · towels every other day · quiet until 10:00 and skip today · towels on request are both real outputs. It is null when the room has no stay or the stay set nothing.

window is the open service window, or null. remaining_minutes counts down from est_minutes against the read instant, and the whole object drops to null the moment nothing is left, because a window whose time is spent is no longer a window. The guest could walk back in.

presence_confidence is 0.9 on a room whose window was opened by an itinerary signal, and null otherwise. It is the only confidence number in the family.

attendant_id and serviced_at are both stamped when a room is marked serviced. The fixture seeds attendant_id on its done and servicing rooms and a serviced_at on the done ones, so the board reads as a floor mid-shift.

reasons is the room's route-reason history, latest first, capped at five per room. The board reads the most recent 200 rows across the whole floor, so a very busy floor can leave an idle room with fewer than its five.

attendants

The three fixture attendants, ordered by floor. First names only, no last names and no contact details.

metrics

MetricWhat it counts
knocks_avoidedroom_serviced events whose knock_avoided param is true. A knock is avoided when the room's preference was never_knock or the room had an open window at the moment it was marked. Running total for the life of the database.
trips_avoidedRooms an attendant is being kept away from right now because the guest asked. skip_today always counts, never_knock counts while no window is open, and mornings_fine counts while the property clock is before quiet_until. Rooms in dnd or done are excluded. A live count, not a running total.
dnd_honoredRooms currently in dnd.
rooms_turned_by_1pm_pctOf the rooms serviced today in property-local time, the share whose serviced_at is before 13:00 local. Zero when nothing was serviced today.
false_empty_pctOf every service_window_closed event, the share whose dropped param is true, meaning the guest came back while an attendant was already in the room. Zero when no window has ever closed.

Nothing here is per attendant. No pace, no rooms per hour, no ranking.

Errors

None. The route always returns 200.

GET /v1/housekeeping/route

One attendant's stops in order, with the line that explains each move. This is the explainability surface.

Auth: unauthenticated in this build.

Request

ParamTypeRequiredMeaning
attendant_id (query)stringrequiredhk-rosa, hk-devon, or hk-amara in the fixture.

No path params, no body.

GET /v1/housekeeping/route

GET /v1/housekeeping/route?attendant_id=hk-devon

Response

JSON

{
  "attendant": { "attendant_id": "hk-devon", "first_name": "Devon", "floor": 13 },
  "stops": [
    { "room_number": "1304", "state": "occupied", "reason": "1304 moved up: departure today", "priority": "departure" },
    { "room_number": "1307", "state": "empty", "reason": "1307 moved up: arrival due", "priority": "arrival_due" },
    { "room_number": "1303", "state": "empty", "reason": "1303 moved up: guest at Saltline Spa, 1h window", "priority": "window" },
    { "room_number": "1301", "state": "occupied", "reason": null, "priority": "held" },
    { "room_number": "1305", "state": "occupied", "reason": null, "priority": "held" },
    { "room_number": "1306", "state": "servicing", "reason": null, "priority": "held" },
    { "room_number": "1308", "state": "occupied", "reason": null, "priority": "held" }
  ]
}

This read is pure. It computes the order and returns it without writing a reason row or emitting an event. The writes happen on sequenceRoute, which every mutating call in this family runs for the affected floor.

The order is fixed:

  1. departure, a room whose stay departs today in property-local time.
  2. arrival_due, a room that is empty with no open window.
  3. window, a room with an open service window, longest remaining window first.
  4. Everything ordinary, with no reason line, because nothing happened to it.
  5. held, the rooms the guest's own preference is holding back.

Rooms in dnd or done are left out of the route entirely, and so is any room whose preference is skip_today.

The priority field can also carry vip, which nothing in this build produces. The stays table has no VIP column, so the tier is skipped rather than faked.

reason is the verbatim line an operator reads. The shapes it takes:

ReasonWhen
1304 moved up: departure todayThe room's stay departs today.
1307 moved up: arrival dueThe room is empty with no window.
1303 moved up: guest at Saltline Spa, 1h windowA window is open and the room has no preference set.
1408 moved up: guest at Lagoon Grill, 1h45m window, pref: never knockA window is open on a room whose stay set a preference.
1408 held: pref never knock, guest in roomThe preference is never_knock and no window is open.
1408 held: quiet until 10:00The preference is mornings_fine and the property clock is still before quiet_until.
1408 dropped: guest returned earlyWritten by closeWindow when the guest came back to a room an attendant had already started.
nullAn ordinary room with nothing moving it and nothing holding it.

Window durations render as 45m, 1h, or 1h45m.

Errors

StatusErrorWhen
400attendant_id requiredThe query string has no attendant_id.
404unknown_attendantNo attendant with that id.

POST /v1/housekeeping/rooms/:room_number/serviced

Mark a room done and write the guest's closing note.

Auth: unauthenticated in this build.

Request

ParamTypeRequiredMeaning
room_number (path)stringrequiredThe room being marked.
FieldTypeRequiredMeaning
attendant_idstringrequiredWho serviced it. Stored on the room, not validated against the attendant table.

POST /v1/housekeeping/rooms/1408/serviced

POST /v1/housekeeping/rooms/1408/serviced
content-type: application/json

{ "attendant_id": "hk-amara" }

Response

JSON

{
  "room": {
    "room_number": "1408",
    "floor": 14,
    "stay_id": "stay-412",
    "state": "done",
    "presence_confidence": 0.9,
    "window_venue": "Lagoon Grill",
    "window_opened_at": "2026-09-07T15:00:00Z",
    "window_est_minutes": 105,
    "attendant_id": "hk-amara",
    "serviced_at": "2026-09-07T15:30:00Z",
    "updated_at": "2026-09-07T15:30:00Z"
  }
}

The room row, not the board tile. The window columns are left where they were, so the venue the room was serviced under stays readable on the row afterwards.

Only a room in servicing or empty can be marked. Everything else is a 409, which is what a second call on the same room gets.

The call emits room_serviced with room_number, attendant_id, venue, knock_avoided, and note. When the room has a stay, note is the line the guest reads on their own phone, Your room was refreshed while you were at Lagoon Grill. with the venue, or Your room was refreshed while you were out. without one. A room with no stay gets a null note and no guest line anywhere. The note comes back on the guest home payload as room_note, an object of text and at, always the latest one for that stay.

Errors

StatusErrorWhen
400attendant_id requiredThe body has no attendant_id.
404unknown_roomNo room with that number.
409invalid_stateThe room is not servicing or empty. An occupied room and an already-done room both fail here.

POST /v1/housekeeping/rooms/:room_number/dnd

Turn do-not-disturb on or off for a room.

Auth: unauthenticated in this build.

Request

ParamTypeRequiredMeaning
room_number (path)stringrequiredThe room being flipped.
FieldTypeRequiredMeaning
onbooleanrequiredtrue sets the room to dnd, false sets it back to occupied. Must be a real boolean.

POST /v1/housekeeping/rooms/1401/dnd

POST /v1/housekeeping/rooms/1401/dnd
content-type: application/json

{ "on": true }

Response

JSON

{
  "room": {
    "room_number": "1401",
    "floor": 14,
    "stay_id": null,
    "state": "dnd",
    "presence_confidence": null,
    "window_venue": null,
    "window_opened_at": null,
    "window_est_minutes": null,
    "attendant_id": null,
    "serviced_at": null,
    "updated_at": "2026-09-07T15:00:00Z"
  }
}

Turning DND off returns the room to occupied whatever it was before, so a room that was servicing or done when DND went on does not get that state back. A call that would not change the state returns the row untouched and emits nothing. Every real flip emits dnd_state_changed with room_number and on, then re-sequences the floor. A room in dnd never appears in a route, and an open window is never opened on one.

Errors

StatusErrorWhen
400on must be true or falseon is missing, or a string, or a number.
404unknown_roomNo room with that number.

Notes

A confirmed booking opens the window. POST /v1/requests/:id/complete calls applyItinerarySignal when the body carried both a venue and a slot. If that stay has a room on the board and the room is not in dnd, the room goes to empty with confidence 0.9, the venue label, the completion instant as window_opened_at, and 105 minutes of estimated duration, which is the property's own turn time for a dinner seating. It emits room_presence_state_changed and service_window_opened, then re-sequences the floor so the room climbs the route. Venue keys from the booking path are relabelled on the way in, so chefs_table becomes Chef's Table, lagoon_grill becomes Lagoon Grill, and main becomes Main dining room. A value that already reads as a label passes through untouched.

A Concierge send or a recognize closes it. POST /v1/stays/:stay_id/concierge and POST /v1/recognize both call closeWindow for that stay. The guest is on their phone, so they are back within reach of the room. The window columns clear, the room returns to occupied, and service_window_closed fires with the venue and a dropped flag. dropped is true when the room was already servicing, and that case also writes the guest returned early reason row. A room already marked done stays done. A stay with no open window closes nothing and emits nothing.

Seven events, one source. Every event in this family is stamped source: quiet-hours.

EventParams
service_pref_setstyle, towel_cadence, quiet_until on a set, or cleared: true on a clear
service_window_openedroom_number, venue, est_minutes
service_window_closedroom_number, venue, dropped
room_presence_state_changedroom_number, from, to, confidence
room_servicedroom_number, attendant_id, venue, knock_avoided, note
dnd_state_changedroom_number, on
route_resequencedattendant_id, floor, moves, order

route_resequenced is the one with no stay behind it, so it is written against the stay id none. It fires only when the order actually changed. A re-sort that moves nothing stays silent, which is what keeps a four-second poll from burying the log.

Three lines reach the activity stream. service_pref_set renders as John asked for no knocks, towels every other day, or John cleared the room preference on a clear. room_serviced renders as 1408 refreshed while John was at Lagoon Grill, and as 1408 refreshed for John when there was no venue. route_resequenced rolls up to Floor 14 route re-sorted, 3 rooms moved. A room with no consented stay produces no guest line and no invented name.

The housekeeping floor is its own fixture. The room numbers on the board do not match the room field on the stay record in this build. John's stay is stay-412 and its stay record says room 412, but on the board that stay owns room 1408 on floor 14. The board's rooms run 1201 to 1408 because the fixture floor was built to read as three plausible floors, and the stay fixtures predate it. Four of the seeded stays are attached to board rooms, stay-509 to 1204, stay-777 to 1304, stay-655 to 1305, and stay-412 to 1408. Every other board room has no stay at all. Anything reconciling a room number against a property system has to treat the two as separate identifiers here.

The tour room is staged on every boot. seedQuietHours deletes stay-412's preference and resets room 1408 to occupied with no window, no attendant, and no serviced time, so a rerun never opens on the last run's preference or the last run's finished room. The other 23 rooms are seeded with INSERT OR IGNORE and keep whatever the last run left.

Venues are Solara Cove venues. The board rotates through Coral Grotto Pools, Saltline Spa, Reef Deck, Sandbar Grill, and Lantern Beach. The first three ride in on seeded windows, the last two arrive on live signals.