---
slug: "surveycto-skill"
source_type: "skill_md"
source_url: "https://cdn.jsdelivr.net/gh/saeehatwalne/surveycto-skill@main/SKILL.md"
repo: "https://github.com/saeehatwalne/surveycto-skill"
source_file: "SKILL.md"
branch: "main"
---
---
name: scto
description: >
  Use this skill for ANY task involving SurveyCTO, XLSForm, ODK, KoboToolbox, or any survey
  form design work. Triggers on: "SurveyCTO", "XLSForm", "ODK", "CAPI instrument", "household
  survey", "phone survey", "CATI form", "survey form design", "field survey tool", ".xlsx form
  definition", "enumerator survey", "roster module", "repeat group", "skip logic", "survey
  instrument", "development economics survey", "impact evaluation survey", "RCT survey", "data
  collection form", "fieldwork instrument", "KoboToolbox", "questionnaire design", "survey
  programming", "pulldata", "choice_filter", "cascading select", "audio audit", "text audit",
  "select_one", "select_multiple", "calculate field", "constraint expression", "relevant
  expression". Use this skill even if the user just says "create a household survey", "add a
  skip logic condition", "build a roster", "write a questionnaire", or "design a form for
  fieldwork". Also use it when the user shares a .xlsx or .csv file that looks like an XLSForm.
---

# SCTO — SurveyCTO / XLSForm Design Skill

## 1. INTERPRET THE REQUEST

Before generating any output, identify:

1. **What is being asked?**
   - New form from scratch → use standard modules from `references/dev-econ-modules.md`
   - Add a module or section to an existing form → produce only that section
   - Fix or debug an existing form → read the problem, identify the issue, produce a corrected snippet
   - Explain or document a form → describe the logic in plain language

2. **Survey type** (if not stated, ask or assume household):
   - Household / panel / tracking survey
   - Agricultural (plot + crop rosters)
   - Health / nutrition / anthropometrics
   - Firm / enterprise
   - CATI / phone survey
   - Back-check / audit form

3. **What output is needed?**
   - Default: produce a `.xlsx` file via Python/openpyxl AND show the sheet content inline
   - If user says "just show me the table" or "paste the XLSForm": sheet content only
   - If user says "give me the xlsx": Python script + file only

4. **Languages**: Default to English only. If user mentions a second language, add `label::[Language (code)]` and `hint::[Language (code)]` columns.

5. **Features used**: Unless user says otherwise, assume:
   - Mobile data collection (Android SurveyCTO Collect), not web forms
   - Standard constraint and relevancy logic
   - Special values: -88 (Don't know), -89 (Refused), -90 (Not applicable)
   - Variable names Stata-compatible (max 32 characters)

---

## 2. STEP-BY-STEP WORKFLOW

### For a new form:

1. **Identify modules needed** from the request
2. **Look up relevant modules** in `references/dev-econ-modules.md` — use them as the base; adapt labels, choice lists, and field names to the survey context
3. **Select field types** using the guide in Section 4 below and `references/field-types.md`
4. **Assign variable names** using the naming convention rules in Section 5
5. **Write skip logic** for all conditional fields using patterns in Section 6
6. **Write constraints** for all numeric and text fields using patterns in Section 7
7. **Add calculate fields** for totals, IDs, module timing, treatment assignment
8. **Assemble the 3-sheet XLSForm** (survey, choices, settings)
9. **Run the quality checklist** in Section 12 before delivering
10. **Produce the output** in the format requested (see Section 9)

### For adding a section or module:

1. State where in the form the section belongs (module prefix, group name)
2. Produce only the new rows — do not repeat the full form
3. Note any dependencies (fields referenced in relevant= or calculation= that must exist in the form)

### For debugging:

1. Identify the symptom: field not showing, constraint not firing, calculation returning blank, repeat not working, pulldata returning empty
2. Check against the debugging patterns in `references/repeats-and-rosters.md` (repeat debugging checklist) and `references/expressions-and-logic.md` (syntax rules)
3. Produce the corrected rows with an explanation of the fix

---

## 3. SHEET STRUCTURE CHEATSHEET

### Survey sheet — minimum columns (always include):
```
type | name | label | hint | required | relevant | constraint | constraint message | appearance | calculation | repeat_count | choice_filter
```

### Survey sheet — full column order:
```
type | name | label | label::[lang] | hint | hint::[lang] | required | required message | relevant | constraint | constraint message | appearance | default | calculation | repeat_count | read only | choice_filter | accuracy_threshold | media:image | media:audio | publishable | minimum_seconds | disabled
```

### Choices sheet — minimum columns:
```
list_name | name | label
```

### Choices sheet — full column order:
```
list_name | name | label | label::[lang] | image | filter | [additional filter columns]
```

### Settings sheet — columns:
```
form_title | form_id | version | default_language | instance_name | public_key | submission_url
```

### Standard settings row example:
```
form_title: Household Survey Round 1
form_id: ke_hh_survey_r1
version: 2026041301
default_language: english
instance_name: concat(${district_code}, '_', ${ea_code}, '_', ${hh_serial})
```

---

## 4. FIELD TYPE SELECTION GUIDE

| Data to collect | Field type | Notes |
|----------------|------------|-------|
| Number, whole, ≤9 digits | `integer` | Use for age, counts, codes |
| Long ID / phone (>9 digits) | `text` + appearance: `numbers` | integer overflows at 9 digits |
| Number with decimals | `decimal` | Plot area, prices, ratios |
| Free text answer | `text` | Open-ended questions |
| Single-choice answer | `select_one listname` | Most categorical questions |
| Multiple-choice answer | `select_multiple listname` | Shocks, crops grown, assets |
| Date | `date` | Birthday, event dates |
| Time | `time` | Interview start/end times |
| Date + time | `dateTime` | Use `now()` in calculation |
| GPS point | `geopoint` | HH location, plot center |
| Hidden calculated value | `calculate` | IDs, totals, flags, lookups |
| Module time snapshot | `calculate_here` | Only for timing — freezes at that point |
| Display-only text | `note` | Instructions, section headers |
| Photo | `image` | Use `max-pixels=1024` parameter |
| Voice recording (question) | `audio` | Use `quality=voice-only` parameter |
| Survey quality monitoring | `audio audit` | See advanced-features.md |
| Data quality timing | `text audit` | Records field-level timing |
| Interviewer identity | `enumerator` | Linked to server dataset |
| Case ID (case management) | `caseid` | Only for case management forms |

**Critical rules:**
- Never use `integer` for phone numbers, national IDs, or any number >999,999,999
- Use `calculate_here` only for module elapsed-time snapshots; never for skip logic
- Use `once()` around any `random()` or `now()` in `calculate` fields
- `geopoint` fields: always set `accuracy_threshold: 10` (meters)

---

## 5. NAMING CONVENTION RULES

### Variable names (survey sheet `name` column)
- **Max 32 characters** (Stata compatibility — hard rule)
- Format: `[module_prefix][question_number]_[short_snake_case]`
- Examples: `m5_age`, `h47_spend_food`, `ag3_plot_area`, `r1_randnum`
- Start with letter or underscore; only letters, digits, underscores
- No hyphens, no spaces, no camelCase

### Module prefixes
| Prefix | Module |
|--------|--------|
| `o` | Observation / form header / identification |
| `m` | Household roster / member-level |
| `h` | Housing / dwelling / assets |
| `f` or `fc` | Food consumption / HDDS |
| `nf` | Non-food expenditure |
| `ag` | Agriculture (plots, crops, inputs) |
| `lv` | Livestock |
| `inc` | Income / livelihoods |
| `sh` | Shocks / coping strategies |
| `hl` | Health |
| `an` | Anthropometrics |
| `we` | Women's empowerment |
| `r` | Randomization fields |
| `bc` | Back-check fields |
| `q` | Enumerator quality / close-out |
| `t` | Timing fields |
| `cal_` | Calculated helper values |
| `new_` | New-round parallel repeat variants (panel surveys) |
| `grp_` | Group names (use triggering field: `grp_o8`, `grp_m14`) |

### Choice list names
- Reusable across modules: `yesno`, `yesnona`, `yesnodk`, `relationship`, `gender`, `edu_level`
- Module-scoped (single question): `m7`, `h54`, `ag12`
- Cascading filter: name by the entity type — `region`, `district`, `ward`, `village`

### Special value codes (always include in hints and constraints)
- `-88` = Don't know
- `-89` = Refused / Decline to state
- `-90` = Not applicable
- `99` = Other (specify)
- `88` = Don't know (for `select_one` — use positive integer for choice value)

---

## 6. SKIP LOGIC (relevant=) PATTERNS

### Show if previous select_one equals a value
```
${consent} = 1
${m2_gender} = 2
${marital_status} = 1
```

### Show for multiple possible values (or)
```
${marital_status} = 2 or ${marital_status} = 3 or ${marital_status} = 4
${reason_left} = 3 or ${reason_left} = 4
```

### Show if select_multiple includes a value
```
selected(${shocks_exp}, '3')
selected(${crops_grown}, '5')
not(selected(${services_used}, '99'))
```

### Show if numeric answer in range
```
${m5_age} >= 18 and ${m5_age} <= 49
${num_plots} > 0
${hl1_sick} = 1 and ${hl2_sought_care} = 1
```

### Show for "other specify" follow-up
```
${reason_code} = 99
selected(${multi_field}, '99')
```

### Consent gate — wrap entire module
```
begin group | grp_main | | relevant=${o9_consent} = 1
  ... all questions ...
end group   | grp_main | |
```

### Show inside repeat for first instance only
```
position(..) = 1
```

### Show if preloaded value exists
```
${preloaded_status} = 1
number(${preloaded_count}) > 0
${pulled_value} != ''
```

### Compound conditions
```
${m2_gender} = 2 and ${m5_age} > 10 and ${m5_age} < 70
${ag0_farms} = 1 and ${ag1_num_plots} > 0
```

---

## 7. CONSTRAINT (constraint=) PATTERNS

### Numeric range with special values (house style — use this for all integer/decimal fields)
```
(.>=0 and .<=120) or .=-88 or .=-89 or .=-90
(.>=0 and .<=100) or .=-88 or .=-89 or .=-90
(.>=0 and .<=30) or .=-88 or .=-89 or .=-90
(.>=0 and .<=1000000) or .=-88 or .=-89 or .=-90
```

### Always include constraint message:
```
constraint message: Enter a value between 0 and 120, or -88 (Don't know), -89 (Refused), -90 (Not applicable)
```

### Cross-field consistency check
```
. >= ${daughter_age}
. <= ${total_land_area}
. > 0 and . <= ${hh_size}
```

### String length (text fields)
```
string-length(.) = 10       # phone number
string-length(.) >= 2 and string-length(.) <= 50
```

### Date constraints
```
. >= date('2000-01-01') and . <= today()
. >= ${start_date}
```

### GPS accuracy (on geopoint fields, use accuracy_threshold column instead)
```
accuracy_threshold: 10
```

### Count constraint for select_multiple
```
count-selected(.) >= 1
count-selected(.) >= 1 and count-selected(.) <= 5
```

---

## 8. CALCULATE FIELD PATTERNS

### Composite household / submission ID
```
concat(${district_code}, '_', ${ea_code}, '_', ${hh_serial})
concat(${region_code}, ${district_code}, ${village_code}, '_', ${hh_num})
```

### Interview duration in minutes
```
(decimal-time(${endtime}) - decimal-time(${starttime})) * 1440
```

### Module elapsed time (use calculate_here)
```
type: calculate_here  name: time_ag_end  calculation: once(duration())
```

### Age from birthdate
```
int((decimal-date-time(today()) - decimal-date-time(${birthdate})) div 365.25)
```

### Frozen datetime stamp
```
once(format-date-time(now(), '%Y-%b-%e %H:%M:%S'))
```

### Random assignment (always frozen with once())
```
once(random())
if(${r1_draw} <= 0.5, 1, 0)
if(${r1_draw} < 0.33, 1, if(${r1_draw} < 0.66, 2, 3))
```

### Sum across repeat
```
sum(${loan_amount})
sum(${ag_plot_area})
```

### Pull from preloaded CSV
```
pulldata('household_list', 'respondent_name', 'hhid_key', ${hhid})
number(pulldata('baseline', 'hh_size', 'hhid_key', ${hhid}))
```

### Dynamic column name in pulldata (panel survey pattern)
```
concat('member', index())   # → member1, member2, member3...
pulldata('baseline_hh', ${calc_pos}, 'hhid_key', ${hhid})
```

### Extract repeat value to flat calculate (always do after roster)
```
indexed-repeat(${m1_name}, ${hh_rg}, 1)
indexed-repeat(${m1_name}, ${hh_rg}, 2)
...
indexed-repeat(${m1_name}, ${hh_rg}, 30)
```

### HDDS or food diversity score
```
if(${fc1_cereals_yn}=1,1,0) + if(${fc2_roots_yn}=1,1,0) + ... + if(${fc12_misc_yn}=1,1,0)
```

---

## 9. ROSTER METHOD SELECTION GUIDE

| Situation | Method | repeat_count |
|-----------|--------|--------------|
| Count known before entering roster (most common) | **Method 2** | `${hh_size}` |
| Count not known; enumerator enumerates spontaneously | **Method 1** | *(blank — + button)* |
| Panel survey: members from prior round preloaded | **Method 3** | `${prior_member_count}` (from pulldata) |
| Nested: plot → crops per plot | **Nested Method 2** | Outer: `${num_plots}`; Inner: `${num_crops}` |
| Follow-up questions per selected multi-select item | **Parallel fixed fields** with `relevant=selected(${field}, 'value')` | *(no repeat)* |

### When to use indexed-repeat() vs ${fieldname}:
- **Inside the repeat**: just `${fieldname}` — refers to current instance
- **Outside the repeat**: `indexed-repeat(${fieldname}, ${repeat_group}, N)` — refers to Nth instance
- **Never use** `indexed-repeat()` inside the same repeat it targets

### Globally unique names rule:
All field names must be unique across the **entire** form, not just within the repeat. Use prefixes:
- Outer repeat: `pl_` for plot fields
- Inner repeat: `cr_` for crop fields

---

## 10. MULTI-LANGUAGE HANDLING

### When to add language columns:
- User specifies a second language, OR
- Form is for a multilingual field context

### Column naming format:
```
label::Swahili (sw)
hint::Swahili (sw)
label::Hindi (hi)
hint::Hindi (hi)
```
Match this format exactly — language name then ISO code in parentheses.

### Settings sheet:
```
default_language: english
```
Or `default_language: English (en)` if that matches your label column name exactly.

### Choices sheet:
```
list_name | name | label | label::Swahili (sw)
yesno     | 1    | Yes   | Ndiyo
yesno     | 0    | No    | Hapana
```

### What NOT to translate:
- `name` column values (stay English)
- `calculation` expressions
- `relevant=` and `constraint=` expressions
- `hint` instructions marked "Instruction:" (translate the content, not the prefix)

---

## 11. REFERENCE FILE POINTERS

| Topic | File |
|-------|------|
| Every field type with syntax and appearance values | `references/field-types.md` |
| Every XLSForm column for survey/choices/settings sheets | `references/xlsform-columns.md` |
| All operators, functions, relevant/constraint/calculate patterns | `references/expressions-and-logic.md` |
| Repeat methods, nested repeats, indexed-repeat(), debugging | `references/repeats-and-rosters.md` |
| Cascading selects, pulldata, server datasets, plug-ins, randomization, encryption, CATI | `references/advanced-features.md` |
| 17 complete copy-paste module blocks (household, ag, health, etc.) | `references/dev-econ-modules.md` |

---

## 12. PRE-UPLOAD QUALITY CHECKLIST

Run through this before delivering any form output:

### Names
- [ ] All `name` values are ≤32 characters
- [ ] All `name` values are globally unique (not just unique within group/repeat)
- [ ] `begin group` and `end group` names match exactly
- [ ] `begin repeat` and `end repeat` names match exactly
- [ ] No spaces, hyphens, or punctuation in variable names (underscores only)

### Types and structure
- [ ] Every `select_one` and `select_multiple` references a list that exists in the choices sheet
- [ ] Every `begin group`/`begin repeat` has a matching `end group`/`end repeat`
- [ ] `repeat_count` field is `integer` type with `required: yes`
- [ ] `calculate` fields have a `calculation` value; visible fields do not use `calculation` unless for dynamic default

### Logic
- [ ] All `relevant=` expressions use `${fieldname}` (not bare names)
- [ ] All `constraint=` expressions use `.` for current field value
- [ ] `once()` wraps every `random()`, `now()`, and `duration()` in `calculate` fields
- [ ] `selected()` always passes choice value as string in single quotes: `selected(${field}, '1')`
- [ ] `indexed-repeat()` second argument is the `name` of the `begin repeat` row
- [ ] `indexed-repeat()` index is guarded with `relevant=${count} >= N` for each N used

### Repeats
- [ ] All field names inside repeats are globally unique (not just repeat-local)
- [ ] Inner repeat's `repeat_count` references a field in the current outer instance
- [ ] No `calculate_here` inside repeat groups

### Choices
- [ ] Every choice list referenced in `type` column exists in choices sheet
- [ ] Choice `name` values are integers where possible (Stata compatibility)
- [ ] Cascading selects: filter column in choices matches `choice_filter` expression column name

### Settings
- [ ] `form_id` has no spaces
- [ ] `instance_name` evaluates to a string (uses `concat()`, `string()` for dates)
- [ ] `version` follows `YYYYMMDDNN` format

### Output (.xlsx)
- [ ] Three sheets in order: survey → choices → settings
- [ ] Row 1 headers bold and frozen (freeze top row)
- [ ] No merged cells
- [ ] All values as plain strings (no formula cells)
- [ ] Columns auto-sized for readability

---

## 13. OUTPUT FORMAT INSTRUCTIONS

### Default output: .xlsx file via Python + inline table

**Step 1 — Show the sheet content inline** as pipe-separated tables:

```
SURVEY SHEET:
type | name | label | hint | required | relevant | constraint | constraint message | appearance | calculation | repeat_count

CHOICES SHEET:
list_name | name | label

SETTINGS SHEET:
form_title | form_id | version | default_language | instance_name
```

**Step 2 — Produce Python/openpyxl script** that creates the .xlsx file:

```python
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.utils import get_column_letter

wb = openpyxl.Workbook()

# Sheet 1: survey
ws_survey = wb.active
ws_survey.title = 'survey'

# Sheet 2: choices
ws_choices = wb.create_sheet('choices')

# Sheet 3: settings
ws_settings = wb.create_sheet('settings')

# Headers — bold and frozen
SURVEY_HEADERS = ['type', 'name', 'label', 'hint', 'required', 'relevant',
                  'constraint', 'constraint message', 'appearance', 'calculation',
                  'repeat_count', 'choice_filter']

CHOICES_HEADERS = ['list_name', 'name', 'label']

SETTINGS_HEADERS = ['form_title', 'form_id', 'version', 'default_language', 'instance_name']

header_font = Font(bold=True)

def write_headers(ws, headers):
    for col, h in enumerate(headers, 1):
        cell = ws.cell(row=1, column=col, value=h)
        cell.font = header_font
    ws.freeze_panes = ws['A2']

write_headers(ws_survey, SURVEY_HEADERS)
write_headers(ws_choices, CHOICES_HEADERS)
write_headers(ws_settings, SETTINGS_HEADERS)

# --- Survey rows ---
survey_rows = [
    # [type, name, label, hint, required, relevant, constraint, constraint_msg, appearance, calculation, repeat_count, choice_filter]
    # INSERT ROWS HERE
]

for r, row in enumerate(survey_rows, 2):
    for c, val in enumerate(row, 1):
        ws_survey.cell(row=r, column=c, value=val)

# --- Choices rows ---
choices_rows = [
    # [list_name, name, label]
    # INSERT ROWS HERE
]

for r, row in enumerate(choices_rows, 2):
    for c, val in enumerate(row, 1):
        ws_choices.cell(row=r, column=c, value=val)

# --- Settings rows ---
settings_rows = [
    # [form_title, form_id, version, default_language, instance_name]
    # INSERT ROW HERE
]

for r, row in enumerate(settings_rows, 2):
    for c, val in enumerate(row, 1):
        ws_settings.cell(row=r, column=c, value=val)

# Auto-size columns
for ws in [ws_survey, ws_choices, ws_settings]:
    for col in ws.columns:
        max_len = max((len(str(cell.value or '')) for cell in col), default=0)
        ws.column_dimensions[get_column_letter(col[0].column)].width = min(max_len + 4, 60)

wb.save('form_output.xlsx')
print('Saved: form_output.xlsx')
```

**Step 3** — Run the script to confirm it executes without error, then report the output filename.

### Inline table output only (when user requests "just show the table"):
Produce pipe-separated tables for each sheet. Include all non-empty columns. Omit columns that are entirely blank for the current form.

### Python script only (when user requests "give me the xlsx"):
Produce the full self-contained script with all rows populated. Include a `print()` confirmation at the end.
