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:
- Define measures — Each YAML file is a standalone questionnaire (e.g., mood, sleepiness, sleep diary)
- Bundle measures — Composite surveys combine multiple measures into a single session using
includes - 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 inquestion_ordercolumn for analysis)source— citable reference, stored with the survey metadataselect_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.
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
| Type | Description | Key Config Fields |
|---|---|---|
likert | Numbered scale buttons | scale_min, scale_max, scale_min_label, scale_max_label |
choice | Single-select from a list | options (list of strings) |
yesno | Yes/No buttons | — |
text | Free-text response | — |
photo | Photo upload | — |
voice | Voice message | — |
cognitive | Browser-based m2c2kit task | assessment, number_of_trials |
time | Time picker | time_mode (clock, scroll, duration) |
webapp_choice | Interactive scrollable choice | options (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:
- It resolves each referenced survey ID from the loaded measures
- Flattens all questions into a single ordered list
- Creates section metadata tracking where each measure starts and ends
- 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.
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
| Field | Description |
|---|---|
provider | Wearable provider: oura, fitbit, withings |
data_type | Data type that triggers evaluation (e.g., daily_sleep, heartrate) |
condition_json | Condition(s) to evaluate on the incoming data |
action_type | What to do: trigger_survey |
action_config | Action parameters: {"survey_id": "..."} |
cooldown_minutes | Minimum minutes between re-triggers (prevents duplicates) |
participant_id | Optional — apply rule to specific participant only |
Condition operators
| Operator | Description | Example |
|---|---|---|
eq | Equals | {"path": "score", "op": "eq", "value": 100} |
neq | Not equals | {"path": "type", "op": "neq", "value": "nap"} |
lt / lte | Less than (or equal) | {"path": "score", "op": "lt", "value": 70} |
gt / gte | Greater than (or equal) | {"path": "hr_average", "op": "gt", "value": 100} |
exists | Field is present | {"path": "score", "op": "exists"} |
hour_between | Current 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_surveysto 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).