API reference
Tour
The guided walkthrough state that keeps the Concierge and Hotel Operator in step, advanced by receipts.
The tour is an eighteen-step walkthrough that runs the Concierge and Hotel Operator side by side on one page, driven by one set of controls. Both apps poll the same state, so a step taken on the guest side is reflected on the operator side within a poll.
A step is satisfied when the platform's own records say the real thing happened after the tour started. There is no scripted playback. The predicates read requests, offers, groups, events, and consent, exactly the tables the product writes in normal use.
GET /v1/tour
Read the current tour state.
Auth: unauthenticated in this build.
Request
GET /v1/tour
GET /v1/tour
No path params, no query params, no body.
Response
Active, sitting on step 0 and waiting for the guest to arrive:
JSON
{
"active": true,
"step": 0,
"total": 18,
"started_at": "2026-08-30T18:00:00Z",
"current": {
"id": "meet-john",
"window": "guest",
"title": "John opens the Concierge",
"instruction": "In the guest window, tap Open your Concierge in the email. The Concierge finds John from the link. If a stay is already open, type SOL-412 into the conversation.",
"watch": "The Property tab's activity stream writes \"John arrived on the guest app\" within seconds.",
"operator_tab": "property",
"auto_advance": true
},
"satisfied": false
}
Inactive:
JSON
{
"active": false,
"step": 0,
"total": 18,
"started_at": null,
"current": null,
"satisfied": false
}
| Field | Type | Meaning |
|---|---|---|
active | boolean | Whether a tour is running. |
step | number | Zero-based index of the current step. Retained after a stop. |
total | number | Step count. 18 in this build. |
started_at | string or null | When the run started. Always null while inactive. |
current | object or null | The current step, or null while inactive. |
current.window | string | Which screen the driver acts in: guest, operator, or gm. |
current.instruction | string | What the driver does. Rendered verbatim in the tour rail. |
current.watch | string or null | What to watch for on the other screen, when there is something. |
current.operator_tab | string or null | The Hotel Operator tab to have open: property, queues, housekeeping, offers, or report. |
current.auto_advance | boolean | Whether the step advances itself once satisfied. |
satisfied | boolean | Whether the current step's predicate is true right now. |
The predicate function itself is never serialized. current carries the seven public fields and nothing else.
Errors
None. The route always returns 200.
POST /v1/tour/start
Stage the demo and start at step 0.
Auth: unauthenticated in this build.
Request
POST /v1/tour/start
POST /v1/tour/start
No path params, no query params, no body.
Response
The same TourStatus shape, freshly started:
JSON
{
"active": true,
"step": 0,
"total": 18,
"started_at": "2026-08-30T18:00:00Z",
"current": {
"id": "meet-john",
"window": "guest",
"title": "John opens the Concierge",
"instruction": "In the guest window, tap Open your Concierge in the email. The Concierge finds John from the link. If a stay is already open, type SOL-412 into the conversation.",
"watch": "The Property tab's activity stream writes \"John arrived on the guest app\" within seconds.",
"operator_tab": "property",
"auto_advance": true
},
"satisfied": false
}
Starting is destructive to the fixture cast, and only to the fixture cast. It clears John's requests, his request-linked bookings, his suggestions, his group memberships, and his consent and recognition. It reseeds Janis so she departs today. It closes any open or pending offer on the three demo plays. It leaves John's previously accepted offer instance in place on purpose, because that instance is the watermark that stops a previous run's questions from re-evidencing the Spa opening before anyone asks. Other guests' requests and bookings are untouched.
Staging writes no business events. A reset is stagecraft, and the activity stream should not narrate it.
Errors
None. Calling start on an already-running tour restages and returns to step 0.
POST /v1/tour/advance
Move to the next step, naming the step you were looking at.
Auth: unauthenticated in this build.
Request
POST /v1/tour/advance
POST /v1/tour/advance
content-type: application/json
| Field | Type | Required | Meaning |
|---|---|---|---|
expected_step | number | required | The step index the caller was rendering. Must be a JSON number. A numeric string is rejected. |
skip | boolean | optional | When exactly true, advance without checking the predicate. Anything else is treated as false. |
JSON
{ "expected_step": 1, "skip": false }
Response
The TourStatus after the move, the same shape GET /v1/tour returns. Advancing off the last step ends the tour: active goes false and step stays at 17.
The expected_step guard
expected_step is a step token, not a target. The caller names the step it was showing, and the server refuses the move if the tour has already left that step:
TS
if (expectedStep !== s.step) throw new Error('stale_step');
This is what makes two watchers safe. The tour page and both apps can watch the same step and both fire an advance when it is satisfied. The first call moves the tour, the second arrives holding the old token and gets a 409 instead of skipping a step. A double tap on one rail behaves the same way. A 409 stale_step means the tour has moved on, so re-read GET /v1/tour rather than retrying with the same token.
Neither 409 changes any state. See core/src/__tests__/tour.test.ts.
Auto-advance and manual steps
Every step carries an auto_advance flag. The server never advances anything by itself. The flag tells the surface what to do when satisfied turns true.
- Auto-advance steps are receipt-gated. The surface polls, sees
satisfied: true, and posts an advance on its own after a short stagger. This is what keeps the tour in lockstep with the real actions, and what lets it fast-forward through work the driver already did. Nine steps behave this way. - Manual steps pause for inspection or an operator action. Five steps behave this way:
planner,report,room-window,room-servicedandfinish. The housekeeping steps use receipts but pause so the viewer can read the reason and mark the room serviced deliberately.
The fourteen steps:
| # | id | Window | Operator tab | Advance | Satisfied when |
|---|---|---|---|---|---|
| 0 | meet-john | guest | property | auto | a guest_recognized event for booking code SOL-412 after the start |
| 1 | consent | guest | none | auto | John's stay carries analytics_consent = 1 |
| 2 | planner | guest | none | manual | always |
| 3 | crew | guest | offers | auto | John is the organizer of a live group created after the start |
| 4 | ask | guest | offers | auto | a spa-topic concierge_asked event on John's stay after the start |
| 5 | opening | operator | offers | auto | a spa-play offer in pending_approval, created after the start, naming John |
| 6 | approve | gm | property | auto | that spa offer is open with an instance for John |
| 7 | book | guest | none | auto | John's spa offer instance is accepted after the start |
| 8 | report | operator | report | manual | always |
| 9 | room-pref | guest | housekeeping | auto | a service_pref_set event on John's stay after the start that is not a clear |
| 10 | table | guest | property | auto | an automatically fulfilled dining request on John's stay reaches done |
| 11 | room-window | operator | housekeeping | manual | a service_window_opened event for room 1408 on John's stay after the start |
| 12 | room-serviced | operator | housekeeping | auto | a room_serviced event for room 1408 on John's stay after the start |
| 13 | finish | operator | report | manual | always |
Every predicate except consent is scoped to started_at, so a pre-tour action never satisfies a step. consent reads the current flag with no timestamp comparison, which works because staging clears John's consent on every start. See tour.ts.
Errors
| Status | Error | When |
|---|---|---|
| 400 | expected_step_required | expected_step is missing or is not a JSON number. |
| 400 | tour_inactive | No tour is running. |
| 409 | stale_step | expected_step does not match the server's current step. |
| 409 | not_satisfied | The step's predicate is false and skip was not true. |
POST /v1/tour/stop
End the run.
Auth: unauthenticated in this build.
Request
POST /v1/tour/stop
POST /v1/tour/stop
No path params, no query params, no body.
Response
The TourStatus after stopping. active is false, current is null, started_at reads null, and satisfied is false.
JSON
{
"active": false,
"step": 4,
"total": 18,
"started_at": null,
"current": null,
"satisfied": false
}
The step index is retained rather than reset. Stopping changes no guest or operator data, and calling stop on an already-stopped tour is a no-op.
Errors
None. The route always returns 200.
Notes
- Every advance clears the openings cache, so a question asked in the guest window reaches the operator Openings strip within one poll instead of waiting out the cache bucket.
- Only
startandadvancechange anything a guest can see.GET /v1/tourwrites nothing at all, which is what makes it safe to poll from both windows at a few seconds apart. - The tour is stored as a single row. There is one tour per service, not one per driver or session.
- Booking codes SOL-412, SNT-4501, and SOL-777 are fixture stays. The steps that name them are checking that recognition happened through three different property systems, which is the point those steps are making.