Guide
Federated services
How the core federates with QR Find, TimeLens, Make-It-Right, and the Itinerary Planner, what crosses each boundary, and what the core keeps afterward.
Four federated services sit next to the Guest Intelli core. QR Find answers guest questions and pushes suggestions back. TimeLens shows the property's history from a photo. Make-It-Right handles service recovery. The Itinerary Planner builds a day-by-day plan across the resort. Each one has a different relationship with the core, and the differences are the point of this guide.
The shared identifier is porter_ref, a UUID stamped once on a stay at
recognition and never regenerated. It carries no name, no room, and no booking
code. The services see the ref. They do not see the stay. Three of the four use
it. The Itinerary Planner is handed no ref at all.
QR Find
QR Find is the question-answering service. Federation runs in both directions, and consent gates every leg of it.
The core telling QR Find a stay exists
porter-feed.ts posts a stay to QR Find's ingest endpoint at two moments: on
recognition when the stay already carries consent, and the moment a guest records
analytics_consent: 1. The body is deliberately thin.
JSON
{
"stay_ref": "<porter_ref>",
"property_slug": "solara-cove",
"checkin_at": "2026-09-04T16:00:00Z",
"checkout_at": "2026-09-08T11:00:00Z"
}
It carries x-gi-service-token, times out at three seconds, and is
fire-and-forget. Failures log one line and vanish. Recognition and consent must
never block on a federated service being slow. A stay with no porter_ref, no configured
URL, or no configured token is skipped outright.
Forwarding a Concierge question
POST /v1/stays/:stay_id/concierge takes one message, between 1 and 500
characters. concierge.ts matches it against the property config's intent
keywords first, using plain case-insensitive phrase containment.
On a matched intent, the reply is the intent's own configured text plus a typed card, and no round trip to QR Find is needed to answer. If the guest consented and a chat function is configured, the message is still forwarded, fire-and-forget, with the reply discarded, so QR Find's question store keeps the words for the Openings read. An unconsented guest's message is never forwarded.
On no matched intent, the core awaits QR Find and returns its answer as the reply
text with no cards. concierge-porter.ts builds that call:
JSON
{
"property_slug": "solara-cove",
"thread_key": "<porter_ref, or a fresh random UUID>",
"channel": "web",
"message": "...",
"stay_ref": "<porter_ref, present only when consented>"
}
A consented stay's porter_ref rides as both thread_key and stay_ref, which
keeps one conversation continuous across the guest web app and the QR Find web
channel. An unconsented call gets a random thread key and no ref at all, so the
words never link to an identity. The call times out at eight seconds and expects
{ "reply": "<string>" }. Anything else, including an unreachable host, falls
back to a fixed line offering a person and a concierge request form.
Consent gating
Consent gates analytics and marketing, never service. The group, offers, and
timelens intents return a consent_gate card to an unconsented guest. Dining,
spa, housekeeping, late checkout, and luggage all work unconsented, exactly as a
direct request always did.
The same rule governs deep links. GET /v1/stays/:stay_id/home returns
concierge_url and timelens_url as null unless the stay has
analytics_consent === 1, has a porter_ref, and the deployment configured that
service's URL. An unconsented guest's tap must never arrive at a service carrying
a ref that resolves to their stay.
QR Find calling back
Two routes accept inbound calls, both requiring x-gi-service-token to match the
configured federation token. With no token configured they return 401 for
everybody.
POST /v1/federation/porter/escalations takes stay_ref, summary, and an
optional conversation_url. The core resolves the ref to a stay, creates a
concierge request on it, and stores provenance in the request details:
note (the summary, trimmed to 200 characters), source: "porter", and the
conversation URL when given (trimmed to 300). It emits porter_escalation. A
concierge request completes at zero value and records no action, because getting
a person on something is service, not revenue. Unknown ref returns 404, empty
summary returns 400.
POST /v1/federation/porter/upsells takes stay_ref, delivery_id, title,
body, emoji, moment, and link. The link must be https. The row is keyed
on delivery_id, so a repeat delivery updates in place rather than duplicating,
and only the first emits porter_upsell_sent. Stored upsells surface on the
guest home as concierge_suggestions, capped at five, unopened first. When the
guest taps one, POST /v1/porter-upsells/:id/open (session-guarded) stamps
opened_at once and emits porter_upsell_opened.
openings-sources.ts adds one more outbound call. The Openings read batches every
active stay's refs into a single POST to QR Find's questions endpoint with
x-gi-service-token, a three-second timeout, and a since timestamp. It returns
{ stay_ref, text, at } rows that become topic evidence. A failure marks the
porter source unavailable in the response while local evidence still returns.
What the core keeps
The porter_ref on the stay. Upsell rows. Concierge requests created from
escalations, with their note and conversation link. Events:
porter_escalation, porter_upsell_sent, porter_upsell_opened, and
concierge_asked. Conversation text is not among them. concierge_asked stores
a character_count and nothing else. The platform remembers what it did, and QR
Find keeps the words under its own retention.
TimeLens
TimeLens is a photo deep link. The core knows two things about it, both from configuration: a web URL and an API base with a service token.
The web URL becomes a link the guest taps. It is assembled in two places, the
home response and the Concierge's timelens intent card, in the same shape:
Code
<TIMELENS_URL>?property=solara-cove&ref=<porter_ref>&src=stay-app
Both are consent-gated and both return null (home) or a consent gate
(Concierge) without it. The core stores no photo, no session, and no result. It
hands over a ref and a link.
The API is read-only and feeds Openings. createTimelensFeaturesSource issues one
GET <TIMELENS_API_URL>/api/engagement/<ref>/features per ref with
Authorization: Bearer <TIMELENS_PARTNER_TOKEN> and a three-second timeout. Of
each returned row it keeps feature_key and evidence_count and drops every
other field. Feature keys map to plain prose through a fixed table in
openings.ts, so a topic reads "explored the beach in TimeLens" rather than a
score or a system name. Results are cached for five minutes and never written to
the database. A failure marks the timelens source unavailable in the openings
response.
The relevant environment variable names are TIMELENS_URL, TIMELENS_API_URL,
and TIMELENS_PARTNER_TOKEN.
Make-It-Right
Make-It-Right is service recovery, and its integration is the thinnest of the three. It is an event emitter. It has no route of its own beyond the shared sink.
POST /v1/events/sink accepts event_type, stay_id, and occurred_at.
Everything else in the body is stored as the event's params. The route is
unauthenticated in this build. Every event it writes is stamped
source: 'make_it_right', hardcoded, so the sink is a Make-It-Right channel by
construction rather than by convention. A body missing any of the three required
fields returns 400 invalid_event.
Three event types are read back, and only these three:
| Event type | Where it surfaces |
|---|---|
friction_detected | Property tab recovery.incidents_today, report recovery.incidents, an activity line reading "Friction flagged for {First}" |
recovery_sent | Property tab recovery.gestures_sent_today, report recovery.gestures_sent, and recovery_spend_usd summed from each event's cost_usd param |
recovery_claimed | Report recovery.gestures_claimed, an activity line reading "{First} claimed the gesture" |
Every make_it_right event also carries a "Make-It-Right" chip in the activity
stream, and the operator context view for a stay returns its full
recovery_events list with parsed params.
That is the whole surface. The core holds no recovery rules, no gesture catalog, no cost model, and no route to create or claim a gesture. It counts what Make-It-Right tells it happened, attributes each item to the right stay, and shows it next to everything else on that stay.
Itinerary Planner
The Itinerary Planner is a separate service that builds a guest a day-by-day plan across the resort. It is a Java and Spring Boot API with a React Native web front end, running on its own host with its own database. None of its code lives in this repo.
It keeps its own venue catalog, 51 venues for Solara Cove, loaded into the planner's own store. The core's recommendation rules and the planner's catalog are separate lists that happen to describe the same property.
The core's whole share of this integration is one link. GET /v1/stays/:stay_id/home returns itinerary_url, built from the deployment's
ITINERARY_PLANNER_URL with any trailing slash normalized away:
Code
<ITINERARY_PLANNER_URL>/?property=solara-cove
It is null when no URL is configured. The Concierge renders it under Plan your
days in the Your stay sheet, and the guest taps through in a new tab.
The link carries the property slug and nothing else. No porter_ref, no stay
id, no room, no booking code. With no ref on it, it is the one deep link in this
guide that is not consent-gated. An unconsented guest gets the same URL a
consented one does, and core/src/__tests__/federation.test.ts pins that.
Nothing flows back into the core from the planner in this build. The planner has no inbound route here, emits no events, and writes nothing to the database. The core does not know whether the guest opened it, what they picked, or what plan came out. One outbound link, no return path. Whatever the guest builds in the planner stays in the planner.