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
}
FieldTypeMeaning
activebooleanWhether a tour is running.
stepnumberZero-based index of the current step. Retained after a stop.
totalnumberStep count. 18 in this build.
started_atstring or nullWhen the run started. Always null while inactive.
currentobject or nullThe current step, or null while inactive.
current.windowstringWhich screen the driver acts in: guest, operator, or gm.
current.instructionstringWhat the driver does. Rendered verbatim in the tour rail.
current.watchstring or nullWhat to watch for on the other screen, when there is something.
current.operator_tabstring or nullThe Hotel Operator tab to have open: property, queues, housekeeping, offers, or report.
current.auto_advancebooleanWhether the step advances itself once satisfied.
satisfiedbooleanWhether 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
FieldTypeRequiredMeaning
expected_stepnumberrequiredThe step index the caller was rendering. Must be a JSON number. A numeric string is rejected.
skipbooleanoptionalWhen 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-serviced and finish. The housekeeping steps use receipts but pause so the viewer can read the reason and mark the room serviced deliberately.

The fourteen steps:

#idWindowOperator tabAdvanceSatisfied when
0meet-johnguestpropertyautoa guest_recognized event for booking code SOL-412 after the start
1consentguestnoneautoJohn's stay carries analytics_consent = 1
2plannerguestnonemanualalways
3crewguestoffersautoJohn is the organizer of a live group created after the start
4askguestoffersautoa spa-topic concierge_asked event on John's stay after the start
5openingoperatoroffersautoa spa-play offer in pending_approval, created after the start, naming John
6approvegmpropertyautothat spa offer is open with an instance for John
7bookguestnoneautoJohn's spa offer instance is accepted after the start
8reportoperatorreportmanualalways
9room-prefguesthousekeepingautoa service_pref_set event on John's stay after the start that is not a clear
10tableguestpropertyautoan automatically fulfilled dining request on John's stay reaches done
11room-windowoperatorhousekeepingmanuala service_window_opened event for room 1408 on John's stay after the start
12room-servicedoperatorhousekeepingautoa room_serviced event for room 1408 on John's stay after the start
13finishoperatorreportmanualalways

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

StatusErrorWhen
400expected_step_requiredexpected_step is missing or is not a JSON number.
400tour_inactiveNo tour is running.
409stale_stepexpected_step does not match the server's current step.
409not_satisfiedThe 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 start and advance change anything a guest can see. GET /v1/tour writes 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.