Skip to main content

Survey Engine

The survey engine loads YAML definitions, resolves composite surveys, and delivers questions to participants. This page explains how to define individual measures, combine them into bundles, schedule delivery, and set up event-driven triggers.

Overview: From Measures to Triggers

The workflow for a researcher follows three steps:

  1. Define measures — Each YAML file is a standalone questionnaire (e.g., mood, sleepiness, sleep diary)
  2. Bundle measures — Composite surveys combine multiple measures into a single session using includes
  3. Trigger delivery — Schedule bundles on a cron schedule, trigger via API, or set up IF-THIS-THEN-THAT rules based on wearable data

Step 1: Define Measures

Each YAML file in src/surveys/ defines one measure. A measure has an id, name, optional source citation, and a list of questions.

Example: Mood (PANAS short-form)

id: mood
name: Mood Questionnaire
description: "Rate how you feel right now on each dimension."
source: "Thompson, E. R. (2007). Development and validation of an internationally
reliable short-form of the PANAS. Journal of Cross-Cultural Psychology, 38(2), 227–242."
randomize: true
questions:
- id: upset
text: "I feel upset right now."
type: likert
scale_min: 1
scale_max: 5
scale_min_label: "not at all"
scale_max_label: "extremely"

- id: alert
text: "I feel alert right now."
type: likert
scale_min: 1
scale_max: 5
scale_min_label: "not at all"
scale_max_label: "extremely"

# ... more items

Key features:

  • randomize: true — shuffles question order each session (stored in question_order column for analysis)
  • source — citable reference, stored with the survey metadata
  • select_n — if set, randomly samples N questions from the pool each session (useful for reducing respondent burden)

Example: Nap Diary with Conditional Branching

id: nap
name: Nap Diary
description: "Questions about napping or dozing today."
source: "Carney, C. E., et al. (2012). The Consensus Sleep Diary. Sleep, 35(2), 287–302."
questions:
- id: nap_count
text: "How many times did you nap or doze today?"
type: choice
options: ["0", "1", "2", "3", "4", "5+"]

- id: nap_duration
text: "In total, how long did you nap or doze?"
type: time
time_mode: duration
max_hours: 4
minute_step: 5
show_if:
question_id: nap_count
operator: not_equals
value: "0"

The show_if condition means nap_duration is only shown when the participant didn't answer "0" to nap_count. Supported operators: equals, not_equals.

caution

randomize: true and show_if cannot be used in the same survey — conditional branching requires a fixed question order.

Example: Karolinska Sleepiness Scale

id: sleepiness
name: Karolinska Sleepiness Scale
source: "Åkerstedt, T., & Gillberg, M. (1990). International Journal of Neuroscience, 52(1-2), 29-37."
questions:
- id: kss
text: "How sleepy do you feel right now?"
type: webapp_choice
options:
- "1: Extremely alert"
- "2: Very alert"
- "3: Alert"
- "4: Rather alert"
- "5: Neither alert nor sleepy"
- "6: Some signs of sleepiness"
- "7: Sleepy, but no effort to keep awake"
- "8: Sleepy, some effort to keep awake"
- "9: Very sleepy, fighting sleep"

Example: Cognitive Assessment Battery

id: cognitive_test
name: Cognitive Assessment Battery
description: "Please complete the following short cognitive tasks."
questions:
- id: symbol_search
text: "Task 1/2 — Symbol Search\nTap the button below to begin."
type: cognitive
assessment: symbol-search
number_of_trials: 6
required: true

- id: color_shapes
text: "Task 2/2 — Color Shapes\nTap the button below to begin."
type: cognitive
assessment: color-shapes
number_of_trials: 6
required: true

Question Types Reference

TypeDescriptionKey Config Fields
likertNumbered scale buttonsscale_min, scale_max, scale_min_label, scale_max_label
choiceSingle-select from a listoptions (list of strings)
yesnoYes/No buttons
textFree-text response
photoPhoto upload
voiceVoice message
cognitiveBrowser-based m2c2kit taskassessment, number_of_trials
timeTime pickertime_mode (clock, scroll, duration)
webapp_choiceInteractive scrollable choiceoptions (list of strings)

Set required: false on any question to let participants skip it.

Step 2: Bundle Measures into Composites

A composite survey combines multiple measures into a single session using includes. This is the main tool for building study-specific survey bundles.

Example: Morning Battery

id: morning
name: ☀️ Morning Questionnaire
description: Good morning! Please answer the following questions about last night's sleep.
includes:
- morning_ksd # Consensus Sleep Diary (sleep quality, latency, wake time)
- bed_exit # Post-bed questions
- sleepiness # Karolinska Sleepiness Scale
- mood # PANAS short-form (randomized within section)
- cognitive_test # Symbol Search + Color Shapes

Example: Evening Battery

id: evening
name: 🌙 Evening Questionnaire
description: Good evening! Please reflect on your day.
includes:
- mood # PANAS short-form
- nap # Nap diary (with conditional branching)
- sleepiness # KSS
- daily_reflection # End-of-day reflection
- cognitive_test # Cognitive tasks

How composites work

When the engine loads a composite survey:

  1. It resolves each referenced survey ID from the loaded measures
  2. Flattens all questions into a single ordered list
  3. Creates section metadata tracking where each measure starts and ends
  4. Each section preserves its own settings (randomize, select_n, allow_change_all)

The participant sees a single continuous survey with section transitions displayed as chat-style messages.

Validation

A survey must have either questions or includes, never both. This is enforced at load time.

Step 3: Schedule or Trigger Delivery

Once your measures and bundles are defined, you control when they're delivered.

Option A: Cron Schedules

Create recurring schedules via the API. Surveys are delivered in each participant's local timezone.

# Morning battery at 8:00 AM daily
curl -X POST http://localhost:8000/api/v1/schedules \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"survey_id": "morning", "cron_expression": "0 8 * * *"}'

# Evening battery at 8:00 PM daily
curl -X POST http://localhost:8000/api/v1/schedules \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"survey_id": "evening", "cron_expression": "0 20 * * *"}'

# Momentary check-ins 3x daily (10am, 2pm, 6pm), weekdays only
curl -X POST http://localhost:8000/api/v1/schedules \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"survey_id": "momentary_checkin", "cron_expression": "0 10,14,18 * * 1-5"}'

# Only for the treatment group
curl -X POST http://localhost:8000/api/v1/schedules \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"survey_id": "cognitive_test",
"cron_expression": "0 12 * * *",
"study_group": "treatment"
}'

Cron expressions use 5 fields: minute hour day month day_of_week (standard cron syntax).

Option B: One-Time Triggers

Trigger a survey immediately or at a specific time:

# Trigger immediately for all participants
curl -X POST http://localhost:8000/api/v1/trigger \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"survey_id": "morning"}'

# Trigger for a specific participant
curl -X POST http://localhost:8000/api/v1/trigger \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"survey_id": "mood", "participant_id": 3}'

# Schedule a one-time delivery
curl -X POST http://localhost:8000/api/v1/schedules \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"survey_id": "morning", "specific_time": "2026-04-01T09:00:00"}'

Option C: IF-THIS-THEN-THAT Rules (Event-Driven)

The rules engine triggers surveys based on real-time wearable data. Rules are evaluated after each webhook delivery from Fitbit, Oura, or Withings.

Example: Trigger morning survey when sleep data arrives

curl -X POST http://localhost:8000/api/v1/rules \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Morning survey on sleep data",
"description": "Send morning battery when sleep data arrives between 5-11 AM",
"provider": "oura",
"data_type": "daily_sleep",
"condition_json": {
"path": "score",
"op": "exists"
},
"action_type": "trigger_survey",
"action_config": {"survey_id": "morning"},
"cooldown_minutes": 720
}'

Example: Trigger mood check when sleep quality is poor

curl -X POST http://localhost:8000/api/v1/rules \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Poor sleep → mood check",
"description": "If sleep score is below 70, trigger a mood check",
"provider": "oura",
"data_type": "daily_sleep",
"condition_json": [
{"path": "score", "op": "lt", "value": 70},
{"path": "_hour", "op": "gte", "value": 6},
{"path": "_hour", "op": "lte", "value": 11}
],
"action_type": "trigger_survey",
"action_config": {"survey_id": "mood"},
"cooldown_minutes": 480
}'

Rule structure

FieldDescription
providerWearable provider: oura, fitbit, withings
data_typeData type that triggers evaluation (e.g., daily_sleep, heartrate)
condition_jsonCondition(s) to evaluate on the incoming data
action_typeWhat to do: trigger_survey
action_configAction parameters: {"survey_id": "..."}
cooldown_minutesMinimum minutes between re-triggers (prevents duplicates)
participant_idOptional — apply rule to specific participant only

Condition operators

OperatorDescriptionExample
eqEquals{"path": "score", "op": "eq", "value": 100}
neqNot equals{"path": "type", "op": "neq", "value": "nap"}
lt / lteLess than (or equal){"path": "score", "op": "lt", "value": 70}
gt / gteGreater than (or equal){"path": "hr_average", "op": "gt", "value": 100}
existsField is present{"path": "score", "op": "exists"}
hour_betweenCurrent hour is in range{"op": "hour_between", "value": [6, 11]}

Multiple conditions in a list are ANDed together.

Putting It All Together

Here's a complete study design using all three steps:

1. Define your measures

src/surveys/
├── mood.yaml # PANAS (10 items, randomized)
├── sleepiness.yaml # KSS (1 item)
├── morning_ksd.yaml # Consensus Sleep Diary
├── nap.yaml # Nap diary (conditional)
├── daily_reflection.yaml # End-of-day questions
├── cognitive_test.yaml # Symbol Search + Color Shapes
├── social.yaml # Social context
└── location.yaml # Current location/activity

2. Bundle into composites

# morning.yaml
id: morning
name: ☀️ Morning Questionnaire
includes: [morning_ksd, sleepiness, mood, cognitive_test]

# evening.yaml
id: evening
name: 🌙 Evening Questionnaire
includes: [mood, nap, sleepiness, daily_reflection, cognitive_test]

# momentary_checkin.yaml
id: momentary_checkin
name: 📋 Momentary Check-in
includes: [mood, social, location]

3. Set up delivery

# Cron schedules
POST /api/v1/schedules → morning at 8 AM daily
POST /api/v1/schedules → evening at 8 PM daily
POST /api/v1/schedules → momentary_checkin at 10am, 2pm, 6pm weekdays

# Event-driven rules
POST /api/v1/rules → IF oura sleep score < 70 THEN trigger mood
POST /api/v1/rules → IF withings bed_out event THEN trigger morning

Deploying changes

After editing or adding YAML files:

  • Telegram: Send /reload_surveys to the bot
  • Programmatic: Restart the bot process

The engine validates all surveys at load time — you'll see errors in the logs if a composite references a missing measure or uses invalid combinations (e.g., randomize + show_if).