API reference

Concierge

The single conversational endpoint the Concierge calls, returning a reply line and typed action cards.

One route carries the whole conversation. The Concierge (guest web) posts the guest's message and gets back a line of text plus zero or more typed cards the screen knows how to render: a prefilled request form, an offer, the group panel, a link, or a consent gate. Nothing that a guest says is stored by the core.

POST /v1/stays/:stay_id/concierge

Answer one guest message with a reply line and typed action cards.

Auth: x-gi-session, matched against this stay_id.

Request

ParamTypeRequiredMeaning
stay_id (path)stringrequiredThe stay the session token belongs to.
FieldTypeRequiredMeaning
messagestringrequiredThe guest's message. Trimmed, then required to be 1 to 500 characters.

POST /v1/stays/stay-412/concierge

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

{ "message": "book a table tonight" }

Response

JSON

{
  "reply": {
    "text": "I can book that for you.",
    "cards": [
      { "kind": "request_form", "request_type": "dining", "prefill_note": null }
    ]
  }
}

text is a single line. cards is an array, possibly empty. Five card kinds exist (see ConciergeCard in concierge.ts):

KindFieldsMeaning
request_formrequest_type, prefill_noteOpen the request composer for that type. prefill_note is a string to seed the note field, or null.
offerofferOne guest offer object, the same shape /v1/stays/:stay_id/home returns in offers.
groupnoneOpen the group panel.
linklabel, urlAn outbound link. Used today for TimeLens.
consent_gatenoneAsk for consent before continuing.

An offer card in full:

JSON

{
  "reply": {
    "text": "Here is what is open for you right now.",
    "cards": [
      {
        "kind": "offer",
        "offer": {
          "instance_id": "6d2b0f61-9c2a-4f0e-9f4a-1c2b3d4e5f60",
          "headline": "Sleep in tomorrow",
          "body": "Keep your room until 2pm on your last day.",
          "label": "Late checkout",
          "price_usd": 25,
          "expires_at": "2026-08-30T22:00:00.000Z",
          "status": "viewed",
          "result_ref": null,
          "intent_note": null
        }
      }
    ]
  }
}

The gated answer for an unconsented guest:

JSON

{
  "reply": {
    "text": "Happy to help with that. One choice first.",
    "cards": [{ "kind": "consent_gate" }]
  }
}

Shapes above are copied from core/src/__tests__/concierge.test.ts and core/src/__tests__/server.test.ts ("POST /v1/stays/:stay_id/concierge over HTTP").

Errors

StatusErrorWhen
400invalid_messagemessage is missing, not a string, empty after trimming, or longer than 500 characters.
401unauthorizedNo x-gi-session header, or the token does not match this stay_id.

Notes

How a message is answered. Intent matching is deterministic phrase containment, case-insensitive, over the intent list in the property config. The first configured intent whose keyword appears in the message wins (see matchIntent in concierge.ts). A message that matches no intent falls through to QR Find, the question-answering service. If QR Find is not wired, or the call fails, the guest gets the honest fallback line "I am having trouble reaching my notes. Want me to get a person on this?" plus a concierge request form.

Which intents consent gates. Consent gates analytics and marketing, never service. group, offers, and timelens require analytics_consent = 1 and otherwise return the consent_gate card. Dining, spa, housekeeping, late checkout, luggage, and help all work for an unconsented guest, exactly as a direct request always did.

The forward to QR Find. A consented guest's intent-matched message is also sent to QR Find, fire and forget, with the stay's porter_ref attached, and the reply is discarded. That keeps the questions store complete for Openings. An unconsented message is never forwarded on the intent path. On the fallback path an unconsented message does reach QR Find, but with no ref and a fresh random thread key, so the words cannot be linked back to a stay (see concierge-porter.ts).

What the core remembers. The route writes no requests and stores no message text. A consented ask emits one concierge_asked event carrying character_count and nothing else. An unconsented ask emits nothing. See core/src/__tests__/concierge.test.ts ("a consented ask writes one concierge_asked receipt carrying a length, never the words").

Two intents read live state. late_checkout returns an open late-checkout offer card when one exists for this stay, and a concierge form prefilled with "Late checkout request" when none does. luggage returns a bell-desk luggage_hold form on the stay's last day and a concierge form prefilled with "Luggage question" on any other day. The offers intent returns at most three open cards, or the line "Nothing open right now. I will speak up when something is." when there are none.

Chips and the usher. The suggested chips and the usher line are not served by this route. They ride on GET /v1/stays/:stay_id/home as chips and usher, computed by journeyForStay in concierge.ts. An unconsented or last-day stay gets its fixed chip set and no usher. Otherwise the first unmet step wins: no dining request yet, then no group yet, then no spa offer instance yet. A stay past all three gets the standard chips and no usher.