DEV Community

Jeffrey Turov
Jeffrey Turov

Posted on

Lux Stay Agent — a hotel travel agent that only works because its content is structured

Sanity Challenge Path One Submission

I run a real 3-star hotel next to Luxembourg's central station. For the Sanity Challenge, instead of building a demo on fake data, I pointed a production AI agent at a Sanity Knowledge Base filled with the hotel's actual operational content — live room prices, the real 54-dish snack menu, transport facts, multilingual FAQs. The result: Lux Stay Agent, a travel assistant that answers travelers' questions (FR/EN/DE) with exact figures it cannot afford to get wrong, and that would be completely useless without structured content.

Live demo (real recorded sessions): https://yasha.phoenix--ai.com/agent/
Sanity project ID: vxozr96i (dataset production)

The problem: answers a hotel can't get wrong

"How much is a double room?" sounds trivial until you realize the price changes daily (our rates sync with Booking.com every night). "How do I get from the airport?" has one correct answer (bus 16, free — Luxembourg made all public transport free in 2020). "What's on the menu?" is 54 items with individual prices living in the hotel's production ordering system.

A keyword search over a website gets you approximate, stale, or wrong answers. An LLM without grounding gets you confident nonsense. What works is an agent that queries structured content — exact fields, exact numbers — through a scoped, read-only window.

The Knowledge Base: 78 real documents

I modeled the hotel's world as six Sanity document types:

Type Content Why it must be structured
hotel Address, phone, check-in/out times, distances (100 m to station, 400 m to center) Exact facts, zero tolerance for approximation
room 3 types, capacity, bed, size, basePriceEur + price-sync note A price is a number field, not prose
menuItem 54 dishes, category, priceEur, availability, ordering note Pulled from the live ordering system (RoomEats)
guide Airport/station/transport guides with facts[] arrays Bus lines, durations, the free-transport rule
faq FR/EN/DE questions & answers The agent answers in the user's language
attraction Sights with walkingMinutesFromHotel, UNESCO flags "What can I visit on foot?" is a numeric query

Every document traces back to a real system: the booking engine I built for the hotel (rates synced nightly from Booking.com), the production QR-ordering database for the menu, and verified local transport facts.

Architecture: agent → Sanity Context MCP → Content Lake

Traveler question (FR/EN/DE)
        │
        ▼
Lux Stay Agent (Python, OpenAI-compatible LLM, function calling)
        │  1. fetches /initial-context over HTTP → schema-aware system prompt
        ▼
Sanity Context MCP endpoint
https://api.sanity.io/v2026-03-03/context/mcp/vxozr96i/production?embeddings=true
        │  tools: schema_explorer, groq_query, array_field_reader
        ▼
Sanity Content Lake — 78 real documents, embeddings enabled
        (semantic search via text::semanticSimilarity)
Enter fullscreen mode Exit fullscreen mode

The agent loop is deliberately boring — that's the point. The intelligence lives in the content model:

def run_agent(question):
    tools = get_tools()  # MCP tools/list -> OpenAI function schemas
    messages = [
        {"role": "system", "content": SYSTEM.replace("{ctx}", initial_context_http())},
        {"role": "user", "content": question},
    ]
    for _ in range(MAX_ROUNDS):
        msg = llm(messages, tools)
        if not msg.get("tool_calls"):
            return msg["content"]           # grounded final answer
        for call in msg["tool_calls"]:
            result = mcp_tool(call["function"]["name"],
                              json.loads(call["function"]["arguments"]))
            messages.append({"role": "tool", "tool_call_id": call["id"],
                             "content": result})
Enter fullscreen mode Exit fullscreen mode

The system prompt is strict: exact figures only via groq_query, never from memory; cite the source document type; if the base doesn't know, say so and hand over to the hotel's phone/email; answer in the user's language.

It only works because the content is structured

Three moments from real sessions:

"Combien coûte une nuit en chambre double et à quelle distance de la gare ?"
The agent fires one groq_query on room (basePriceEur: 95) and one on hotel (distanceToStationM: 100). A keyword search would find a page mentioning "double room" and "station" — it would not reliably bind 95 € to this room type on this date.

"Quels plats avec du kebab, et à quel prix ?"
The menu is 54 menuItem documents with name and priceEur as queryable fields. The agent answers with the exact list and prices — and surfaces the pricing contradiction honestly: each item carries the Wolt delivery price and the note that ordering in-room via QR is 15–25% cheaper. Both claims, with their sources, side by side — exactly what structured content enables.

"Was kostet ein Einzelzimmer und wie komme ich vom Flughafen zum Hotel?"
German question → German answer, because the FAQ documents are tagged language: "de", while the room price still comes from the language-neutral numeric field. Translation happens at the LLM layer; facts stay exact at the content layer.

The agent replaying a real session — user question, MCP tool calls, grounded answer

Rigor: I tested the base before trusting the agent

Before writing the agent, I ran a 15-assertion QA battery against the Context MCP endpoint itself: schema visibility for all 6 types, exact price queries, reference resolution (room.hotel->name), document counts (54 menu items), guide facts, semantic search with embeddings, and multilingual retrieval. 15/15. Then a 5-question battery against the full agent loop (German, menu prices, UNESCO sights, English, free transport) asserting exact figures in the answers and actual tool usage. 5/5. The demo page replays these unedited sessions.

What I learned

  • /initial-context over HTTP is a real optimization: injecting the compressed schema into the system prompt saved a tool call per conversation and visibly improved the agent's GROQ accuracy on the first try.
  • Embeddings were one flag (embeddings=true) but needed enabling per-dataset (sanity datasets embeddings enable) — the error message was clear, the fix took a minute with the CLI once I had a token with the right grant (deploy-studio role for hosting, developer for embeddings).
  • Scope by default: the read-only token plus the Context endpoint's design meant I could point an agent at production hospitality data without ever risking a write. For a business dataset, that's the difference between "fun demo" and "deployable".
  • The hard part was never the agent — it was deciding what deserved to be a field. walkingMinutesFromHotel as a number turns "what can I visit?" into a sortable query. That modeling decision is the whole game.

Try it, read it, reuse it

If you have content an agent can't afford to get wrong — prices, inventory, schedules, errata — structure it, scope it, and let the MCP endpoint do the rest. The agent is the easy part.

Top comments (0)