Skip to content

Museum API + Dashboard

dataland-museum is the live visitor telemetry service. It is the only service that bridges the external Refik Anadol Studio data-center redis ("RDC") into the Dataland stack. It subscribes to the RDC pub/sub feed, holds the latest biometric + position payload per wearable in an in-process cache, normalises each event onto the internal museum:telemetry stream, mirrors RDC's active-ticket list at 1 Hz, and serves enriched vitals + room/chapter metadata to the Agent, the curator dashboard, and the Notification pipeline.

Containers dataland-museum (museum-api), dataland-simulator (museum-simulator, optional)
Image dataland/museum-api:${IMAGE_TAG} / dataland/museum-simulator:${IMAGE_TAG} (one Dockerfile, two entrypoints)
Public port ${MUSEUM_PUBLIC_PORT:-4144} → container :5001 (Cloudflare → museum.dataland.chat)
Internal URL http://dataland-museum:5001
Memory / CPU 512 MB / 1 core
Healthcheck GET /health
Entrypoint uvicorn run:app (museum-api); python main.py (simulator, --profile simulator only)

Recent changes (2026-06-03 → 2026-06-04)

  • DAT-289 — RDC values that are stored as plain UTF-8 rather than msgpack (Q-SYS server-health strings, per-room audio-player flags) now decode correctly via a msgpack-then-UTF-8 fallback. New endpoints /api/qsys and /api/audio expose them.
  • DAT-287 / DAT-290 — the 1 Hz active-ticket loop now also emits a visit_ended telemetry event when a ticket leaves Visitors:ActiveTicketIDs (checkout). The notification worker turns that into the "How was the experience?" push. A /api/tickets/{id}/session-status endpoint exposes the raw checkout signals (in_active_tickets, kiosk_outro).
  • DAT-288default_reference_* placeholder images are purged from chapters.json (and GCS cobanov-public/chapters); the loader drops any remaining placeholder defensively.
  • GB mapping — ROOM_TO_GALLERY / GALLERY_ART_IDS now cover GA/GB/GC/GD.

What it does

  • RDC subscriber — opens a PSUBSCRIBE to the external RDC redis (RDC_REDIS_URL) on five channel patterns (BioSensors, BlueIoTSensors, Visitors:Register, per-ticket Status, per-ticket AssignDevice) and keeps the latest decoded payload per wearable in an in-process RDCStateCache.
  • Telemetry bridge — when MUSEUM_TELEMETRY_BRIDGE_ENABLED=true (default), every BioSensors message is normalised and XADD-ed to museum:telemetry on dataland-redis, stamped simulator_source=rdc-bridge. The notification worker tails that stream.
  • Active-ticket mirror — replicates RDC's Visitors:ActiveTicketIDs into the local dataland-redis SET museum:active_ticket_ids every 1 Hz, atomically, so the agent and the mobile app can answer "is this ticket live?" without touching RDC.
  • visit_ended emit (DAT-287/290) — in the same 1 Hz loop, detects tickets that dropped out of the active set (active → passive = checkout) and publishes a visit_ended event onto museum:telemetry.
  • Vitals APIGET /api/tickets/{ticket_id}/vitals composes the agent-facing vitals dict (heart rate, skin conductance, room, chapter, position, ambient AQI, crowd density) from the SET-key surface + the subscriber cache.
  • Room / ambient / Q-SYS / audio / kiosk APIs — read-only views over the RDC data plane for the dashboard and operators.
  • Dashboard — a Jinja-rendered live monitor at /, gated by MUSEUM_PASSWORD, fed over a single WebSocket (/ws) that piggybacks vitals + AQI + visitor list.
  • Simulator (optional)dataland-simulator (main.py) generates synthetic telemetry directly onto museum:telemetry. It exists so the notification pipeline has events even when no real visitors are in the building. The museum-api process never reads from dataland-redis.

Architecture

graph LR
  subgraph RDC["RDC redis (Refik Anadol Studio, read-only)"]
    BIO["Wearables:WatchDevices:*:BioSensors"]
    POS["...:BlueIoTSensors"]
    VIS["Visitors:* (Status / Register / AssignDevice / ActiveTicketIDs)"]
    GAL["Galleries:*:SceneControl:*"]
    IO["IO:AQI:* / Rooms:DATALAND:Qsys:* / Rooms:*:Audio_Player_*"]
  end

  subgraph M["dataland-museum (museum-api)"]
    SUB["RDCSubscriber<br/>PSUBSCRIBE → RDCStateCache"]
    READ["rdc_reader<br/>on-demand MGET/GET"]
    LOOP["1 Hz broadcast loop"]
  end

  BIO -.PSUBSCRIBE.-> SUB
  POS -.PSUBSCRIBE.-> SUB
  VIS -.PSUBSCRIBE / GET.-> SUB
  GAL -.GET.-> READ
  IO -.GET.-> READ

  SUB -.XADD museum:telemetry.-> RD[(dataland-redis)]
  LOOP -.SADD museum:active_ticket_ids.-> RD
  LOOP -.XADD visit_ended.-> RD
  READ -.GET /api/tickets/{id}/vitals.-> AG[dataland-agent]
  LOOP -.WS /ws.-> DASH["dashboard browser"]
  RD -.XREADGROUP.-> NOTIF[dataland-notification]

Two Redis servers

This is the only service that talks to both redis instances. They serve different purposes:

Redis Purpose Direction
RDC_REDIS_URL (RAS data center) Production data plane — live BioSensors / BlueIoT PUBLISH feed + the Visitors:* / Galleries:* / IO:* SET surface read only (PSUBSCRIBE + on-demand GET/MGET)
dataland-redis (internal, on dataland-network) museum:telemetry fan-out + museum:active_ticket_ids mirror write (XADD, SADD/RENAME)

The museum API itself does not read museum data from dataland-redis — that path exists purely to feed the notification pipeline and to mirror the active list for the agent/mobile.

No fallback for the RDC redis

RDC_REDIS_URL is validated at config-load time in app/config.py — if it is empty the service raises RuntimeError and refuses to start. There is no host/port fallback and no substitute store. If the RDC redis is unreachable, museum-api streams no data; it does not pretend to work against a local container. The URL accepts redis://user:pw@host:6379/0 or rediss://…:6380/0 for TLS.

RDC data decode: msgpack + plain UTF-8 (DAT-289)

The RDC redis uses mixed encoding. Most keys and pub/sub payloads are msgpack, but a handful are stored as plain UTF-8 strings:

  • Rooms:DATALAND:Qsys:* server-health values (e.g. "5.03%", "52.0°C", and a plain STATUS label).
  • Rooms:<room>:Audio_Player_<n>_{Playing,Stopped} flags.

rdc_reader._decode_value() therefore tries msgpack.unpackb(...) first and falls back to raw.decode("utf-8"). Usage/temperature strings are additionally parsed to floats by _strip_numeric() (stripping % and °C), so each Q-SYS metric is returned as both a raw string and a parsed value.

Where the encoding map lives

The canonical key + channel + encoding reference is obsidian.md/dataland/data-flow/museum-rdc-redis-data-dictionary.md. The code cites it directly. When in doubt about a key's shape, read that first.

Subscribed channels

The RDCSubscriber (app/services/rdc_subscriber.py) PSUBSCRIBEs to:

Pattern Cache slot Notes
Wearables:WatchDevices:*:BioSensors cache.bio[serial] ~9 fields: Heart_Rate_Value, Skin_Conductivity_Value, Skin_Temperature_Value, Accelerometer_Value, Activity, Steps, Battery_1, … Drives the telemetry bridge.
Wearables:WatchDevices:*:BlueIoTSensors cache.position[serial] BlueIoT_Position (xy) + Room_ID.
Visitors:Register cache.register_events (ring, 50) Onboarding control plane; surfaced to the dashboard.
Visitors:*:Status cache.visitor_status[ticket] Per-ticket status updates.
Visitors:*:AssignDevice cache.assign_device_events (ring, 50) Rebinds a watch to a ticket; clears the serial→ticket map for immediate effect.

The subscriber loop is resilient: on ConnectionError/OSError it backs off exponentially (1 s → 30 s cap) and re-subscribes. A TCP timeout to RDC is the common failure mode and is not reflected in /health (see below).

Heart-rate hold-through

The wrist PPG intermittently reports 0 BPM on dropout / off-wrist / motion. 0 is not a real reading, so the subscriber records the last real (> 0) heart rate per serial in cache.last_valid_hr, and compose_vitals holds that value through gaps for up to HR_HOLD_WINDOW_SECONDS (120 s). Beyond the window, heart_rate is reported as null rather than 0. The response flags heart_rate_held: true when a held value is being surfaced. This is part of the vitals physiological-sanity work (DAT-285).

Serial → ticket resolution (telemetry bridge)

A BioSensors message arrives keyed by watch serial, but museum:telemetry events must carry a ticket_id. The subscriber lazily resolves the binding by:

  1. Reading Visitors:ActiveTicketIDs from RDC.
  2. SCAN-ing Visitors:*:EmpaticaDeviceID and inverting it to a serial → ticket map (preferring tickets that are currently active).
  3. Caching the map for _SERIAL_TICKET_TTL_SECONDS (60 s); an AssignDevice event clears it immediately.

If no binding exists yet (visitor hasn't completed device assignment), the message is counted as skipped_no_ticket and not bridged. Each bridged event is enriched with room_code, chapter_id, chapter_name, art_name, and etemp before the XADD.

Bridge metrics (DAT-131)

GET /api/bridge/metrics exposes the three outcomes plus freshness:

Field Meaning
published BioSensors messages successfully XADD-ed.
skipped_no_ticket No serial→ticket binding yet (expected setup race).
publish_failed Redis hiccup / build error (operators triage).
last_published_at Wall-clock of the last successful publish.
seconds_since_last_published Derived freshness (-1.0 = nothing ever published).
bridge_enabled Reflects MUSEUM_TELEMETRY_BRIDGE_ENABLED.

Active-ticket mirror + visit_ended (DAT-287/290)

A single 1 Hz loop (_rdc_broadcast_loop) drives both the mirror and the checkout signal:

sequenceDiagram
    participant L as 1 Hz loop
    participant RDC as RDC redis
    participant DR as dataland-redis
    participant N as notification worker
    L->>RDC: GET Visitors:ActiveTicketIDs
    RDC-->>L: [ticket_001, ticket_002]
    L->>DR: stage SADD museum:active_ticket_ids:staging → RENAME (atomic swap)
    L->>L: prev - current = checked-out tickets
    L->>DR: XADD museum:telemetry {event_type: visit_ended} (per checked-out ticket)
    N->>DR: XREADGROUP → VisitEndedRule → "How was the experience?" push
  • Mirrormuseum:active_ticket_ids is rewritten every tick to match RDC verbatim (atomic stage-then-RENAME; deleted when RDC reports zero). No TTL, no debounce — RDC is trusted as the source of truth.
  • Contract — the agent reads the exact key museum:active_ticket_ids from the same dataland-redis (dataland-agent/app/services/session_state.py). The key is a fixed constant (ACTIVE_TICKETS_MIRROR_KEY), not env-overridable, so writer and reader can never drift.
  • Resilience — the mirror + visit_ended only run after a successful RDC read; a failed read skips the whole tick and leaves the last good set in place rather than wiping it.
  • Fire-once is the consumer's job — museum-api only emits the raw visit_ended event on the active→passive edge. The dedupe/fire-once state lives in the notification worker (DAT-287).

Both behaviours are independently gated: MUSEUM_ACTIVE_TICKETS_MIRROR_ENABLED and MUSEUM_TELEMETRY_BRIDGE_ENABLED (each default true), using separate async redis clients.

Room codes → galleries → names

RDC writes a wearable's location as a RoomCode. The service maps that to a gallery whose Galleries:<gallery>:<art_id>:SceneControl:* keys describe the active chapter:

ROOM_TO_GALLERY = {"GA": "GA", "GB": "GB", "GC": "GC", "GD": "GD"}  # (1)!
GALLERY_ART_IDS = {"GA": "ga_art_v1", "GB": "gb_art_v1",
                   "GC": "gc_art_v1", "GD": "gd_art_v1"}  # (2)!
  1. Identity map of the four gallery room codes GA/GB/GC/GD. Any RoomCode not present here is treated as non-gallery, so chapter fields resolve to null and _classify_corridor() decides lobby / onboarding / corridor instead.
  2. Each gallery resolves to a single canonical art id used to read its Galleries:<gallery>:<art_id>:SceneControl:* keys. Note GB collapses the legacy b1/b2/b3_art_v1 per-zone activations down to gb_art_v1 — the wearable only ever reports the gallery code GB at runtime.

Rooms not in the map are treated as non-gallery — chapter fields come back null. _classify_corridor() further labels LOlobby, ONonboarding, empty/CORRIDOR → corridor, everything else → gallery.

The agent never speaks bare room codes to visitors — it translates to the human gallery names (DAT-281). The canonical mapping:

Room code Gallery name
GA Data Pavilion
GB Latent Gallery
GC Infinity Room
GD The Sanctuary
ON Discovery Portal (onboarding)
LO Lobby

GB vs B1/B2/B3 in the catalog

chapters.json predates the gallery consolidation: its GB artworks still carry per-zone room hints in their chapter ids (MDR-B1-* Machine Dreams, MDR-B2-* Data Universe, MDR-B3-* Machine Drawings) and b1/b2/b3_art_v1 activations. At runtime the wearable reports the gallery code (GB), which resolves through GALLERY_ART_IDS["GB"] = "gb_art_v1".

chapters.json catalog

app/data/chapters.json is the static room/chapter/artwork catalog, loaded once at startup by load_chapters_from_json() and indexed by chapter_id. It holds 26 raw rows across four galleries:

Gallery Rows Artwork(s)
GA (Data Pavilion) 14 Machine Dreams : Rainforest (MDR-CH-*)
GB (Latent Gallery) 6 Machine Dreams, Data Universe, Machine Drawings
GC (Infinity Room) 2 Ruwe Pinu
GD (The Sanctuary) 4 Machine Hallucinations: Rainforest

Each loaded chapter carries chapter_id, chapter_name, room_code, art_name, activation_name, scent_activation, reference_images, synthetic ambient values (lux/noise/temp), and an intensity label.

default_reference purge (DAT-288)

Placeholder default_reference_* filenames have been removed from chapters.json and from the GCS cobanov-public/chapters bucket. The loader is defensive too: it skips any reference image that is empty or whose name (lowercased) starts with default_reference. These placeholders were never in Qdrant, so no RAG re-ingest was needed. 31 real reference images remain (e.g. cellImage_0_0.jpeg).

Reference image filenames are joined to full URLs at serve time: ${GCS_PUBLIC_BASE_URL}/${GCS_BUCKET_NAME}/chapters/<file> (default https://storage.googleapis.com/cobanov-public/chapters/<file>).

API endpoints

All routes are served by app/web/server.py.

Operations

Method Path Purpose
GET /health Liveness only — does not verify the RDC connection.
GET / Live visitor monitor (HTML; redirects to /login when auth configured).
GET /login Password login page.
GET /api/bridge/metrics Bridge counters + freshness (DAT-131).

Telemetry

Method Path Purpose
GET /api/tickets/{ticket_id}/vitals Composed vitals + chapter details + interpretations. The agent's get_visitor_vitals source. 404 when no RDC mapping.
GET /api/tickets/{ticket_id}/session-status Raw checkout signals: in_active_tickets, kiosk_outro, outro_kiosk_id (DAT-287/290).
GET /api/active-tickets Live ticket ids from Visitors:ActiveTicketIDs (read fresh each call).
GET /api/aqi Per-room Pimoroni Enviro+ payloads from IO:AQI:* (one MGET). temp is °F.
GET /api/qsys Q-SYS server-health from Rooms:DATALAND:Qsys:* (raw + parsed, DAT-289).
GET /api/audio Per-room audio-player playing/stopped flags (DAT-289).
GET /api/onboarding/kiosks Onboarding kiosk lifecycle (scene + stage data) from Rooms:ON:Kiosks:*.
GET /api/simulator/visitors Active visitors synthesised from Visitors:ActiveTicketIDs (+ user enrichment).
GET /api/visitors/{visitor_id}/vitals Deprecated alias for /api/tickets/{id}/vitals.

Museum content

Method Path Purpose
GET /api/chapters All unique chapters with full reference-image URLs.
GET /api/rooms/{room_code}/chapters Chapters for one room code (GA, GB, GC, GD).
GET /images/{filepath} Serve a local chapter image (path-traversal hardened, DAT-121).

Realtime

Method Path Purpose
WS /ws Live monitor stream. Pushes per-ticket vitals (1 Hz), {type:"aqi"} (1 Hz), and {type:"visitor_list"} (every 5 s) on one socket (DAT-135). Closes 4401 without a valid session cookie.

Vitals shape (selected fields)

compose_vitals translates RDC field names + units into the agent-facing schema. Every field is nullable:

{
  "ticket_id": "ticket_001",
  "status": "Active",
  "empatica_device_id": "...", "scent_device_id": "...",
  "room_code": "GA", "zone": 1,
  "is_in_corridor": false, "location_type": "gallery",
  "position_xy": [12.3, 4.5], "position_room_id": 1,
  "chapter_id": "MDR-CH-I-A", "rt_chapter": "...", "dds_chapter": "...",
  "is_playing": true, "playhead": 1234, "stack": "...",
  "heart_rate": 78, "heart_rate_held": false,
  "skin_conductance": 0.42, "skin_temperature": 33.1,
  "activity": 1, "activity_state": "active", "steps": 210, "battery": 88,
  "watch_alive": true, "is_alive": true, "last_bio_seen_seconds_ago": 0.7,
  "ambient_temp": 22.0, "ambient_lux": 350.0, "ambient_humidity": 41.0,
  "crowd_density": 6
}
  • is_alive reflects bio freshness within RDC_WATCH_STALE_AFTER_SECONDS.
  • crowd_density (Augmenta cluster count) exists for GA only today.
  • ambient_temp is converted RDC °F → °C; ambient_lux/humidity come from IO:AQI:<gallery>.

Dashboard auth

  • Single shared password (MUSEUM_PASSWORD). Session cookie is HMAC-signed with MUSEUM_SESSION_SECRET (falls back to the password if unset); 12-hour TTL (MUSEUM_AUTH_SESSION_MAX_AGE_SECONDS=43200).
  • MUSEUM_AUTH_COOKIE_SECURE defaults to true in code / .env.example (production sits behind TLS); the infra compose.yml overrides it to false for the host-port path unless TLS is in front. Local dev over http://localhost must set it false, otherwise the browser silently drops the Secure cookie on each redirect.
  • Per-IP rate limiting on the login path (DAT-122); the constant-time password compare runs only after the rate-limit check.
  • The /ws WebSocket enforces the same session cookie (close code 4401).

Key env vars

# RDC data plane (required, no fallback)
RDC_REDIS_URL=redis://default:***@<rdc-host>:6379/0  # (1)!
RDC_WATCH_STALE_AFTER_SECONDS=10.0  # (2)!

# Internal redis (telemetry bridge + active-ticket mirror sink)
REDIS_HOST=dataland-redis
REDIS_PORT=6379
REDIS_DB=0
REDIS_PASSWORD=***                                # (3)!
MUSEUM_TELEMETRY_BRIDGE_ENABLED=true  # (4)!
MUSEUM_ACTIVE_TICKETS_MIRROR_ENABLED=true  # (5)!

# Dashboard auth
MUSEUM_AUTH_ENABLED=true
MUSEUM_PASSWORD=***
MUSEUM_SESSION_SECRET=***  # (6)!
MUSEUM_AUTH_SESSION_MAX_AGE_SECONDS=43200  # (7)!
MUSEUM_AUTH_COOKIE_SECURE=true                    # (8)!

# Reference image + agent enrichment
GCS_BUCKET_NAME=cobanov-public
GCS_PUBLIC_BASE_URL=https://storage.googleapis.com
AGENT_BASE_URL=http://dataland-agent:4141
AGENT_SERVICE_TOKEN=***                           # (9)!
  1. The external Refik Anadol Studio data-center redis. Validated at config-load time in app/config.py — if empty the service raises RuntimeError and refuses to start. There is no host/port fallback. Accepts redis://user:pw@host:6379/0 or rediss://…:6380/0 for TLS.
  2. Bio freshness window (seconds). A watch whose last BioSensors message is older than this is reported is_alive: false in the vitals payload.
  3. DAT-76 requirepass on the internal dataland-redis. Required to XADD/SADD against the telemetry + active-ticket-mirror sink.
  4. Default true. Gates the BioSensors → museum:telemetry bridge; disabling it stops the notification worker from receiving live events.
  5. Default true. Independently gates the 1 Hz museum:active_ticket_ids mirror (separate async redis client from the bridge).
  6. HMAC key for the dashboard session cookie. Falls back to MUSEUM_PASSWORD if unset — set it explicitly so rotating the password doesn't invalidate sessions.
  7. Session cookie TTL in seconds (43200 = 12 h).
  8. Defaults true in code / .env.example since production sits behind TLS. Set false only for local http://localhost, otherwise the browser silently drops the Secure cookie on each redirect and login loops.
  9. Authorises the museum's GET ${AGENT_BASE_URL}/v1/service/tickets/{ticket}/user enrichment lookups. Without it, enrichment is skipped (tickets still list, just without user_id / external_id mapping).

Why the museum needs an agent token

/api/simulator/visitors and the dashboard visitor list enrich each ticket with user_id / external_id by calling GET ${AGENT_BASE_URL}/v1/service/tickets/{ticket}/user. Lookups are cached (60 s positive, 300 s negative) in a bounded cache so RDC tickets that were never registered through the mobile app don't flood the agent's Postgres pool. Without AGENT_SERVICE_TOKEN, enrichment is skipped (tickets still list, just without user mapping).

Simulator (dataland-simulator)

Built from the same image, run with command: ["uv","run","python","main.py"] under the simulator compose profile. It seeds default visitors and publishes synthetic telemetry to museum:telemetry every second so the notification rules have events when the building is empty. Events are stamped simulator_source (museum-ui / default-seed); default-seed events set notification_enabled=false so they don't trigger pushes. The simulator is the only writer that the museum-api process does not own, and museum-api never reads back from dataland-redis.

Playback / simulation isolation

Never replay museum-simulation Avro or simulator output into the live dataland-redis. If you need to exercise playback, spin up a dedicated redis container. Mixing simulated events into the live stream pollutes the notification pipeline and the dashboard.

Reaching it

# Public:
curl -fsS https://museum.dataland.chat/health  # (1)!

# Direct host:
curl -fsS http://localhost:4144/health

# Vitals (requires session cookie or, in production, fronting auth):
curl -fsS http://localhost:4144/api/tickets/ticket_001/vitals  # (2)!

# Live ticket list + bridge freshness:
curl -fsS http://localhost:4144/api/active-tickets
curl -fsS http://localhost:4144/api/bridge/metrics  # (3)!
  1. /health is liveness only — it confirms the FastAPI process answers but does not verify the RDC connection, so a green health check can still mean zero telemetry is flowing.
  2. The agent's get_visitor_vitals source. Returns 404 when no RDC serial→ticket mapping exists yet for the ticket.
  3. Best single freshness signal: seconds_since_last_published == -1 means nothing has ever been bridged, a large positive value means the bridge stalled (commonly a TCP timeout to the RDC host).

When the dashboard looks healthy but data is stale

/health only checks that the FastAPI process answers; it does not verify the RDC connection. To confirm real telemetry is flowing:

# Bridge freshness (best signal). seconds_since_last_published == -1 means
# nothing has ever been bridged; a large positive number means the bridge stalled.
curl -fsS http://localhost:4144/api/bridge/metrics

# Stream depth on the internal redis:
docker exec dataland-redis sh -c 'redis-cli -a "$REDIS_PASSWORD" --no-auth-warning XLEN museum:telemetry'  # (1)!

# Active-ticket mirror (should match RDC's Visitors:ActiveTicketIDs):
docker exec dataland-redis sh -c 'redis-cli -a "$REDIS_PASSWORD" --no-auth-warning SMEMBERS museum:active_ticket_ids'  # (2)!

# Logs for the RDC subscriber:
docker logs --tail 50 dataland-museum 2>&1 | grep -i rdc  # (3)!
  1. XLEN on the bridge's output stream. --no-auth-warning suppresses the -a password warning; the password is read from the container's own REDIS_PASSWORD (DAT-76 requirepass) so it never appears in the command.
  2. Reads the mirrored SET directly. Its membership should match RDC's Visitors:ActiveTicketIDs verbatim — the mirror is rewritten every tick via atomic stage-then-RENAME, so a mismatch points at a stalled 1 Hz loop, not a sync race.
  3. Surfaces the RDC subscriber's reconnect/backoff lines. A TCP timeout to the RDC host (usually a downed tailnet route) shows here even though /health stays green.

A common failure mode is a TCP timeout to the RDC redis — usually because the tailnet route to the RDC host is down. The subscriber keeps retrying with exponential backoff; nothing in /health reflects it, but seconds_since_last_published climbs and skipped_no_ticket / cache staleness become visible.

  • Agent — consumes vitals + active-ticket mirror; get_visitor_vitals / get_room_info / get_scene_flow.
  • Notification — consumes museum:telemetry, including visit_ended.
  • RAG — holds the museum sections/scenes/overview knowledge the agent searches.
  • Redis & data storesdataland-redis vs the external RDC redis.