原始内容
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:
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
- New form from scratch → use standard modules from
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
What output is needed?
- Default: produce a
.xlsxfile 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
- Default: produce a
Languages: Default to English only. If user mentions a second language, add
label::[Language (code)]andhint::[Language (code)]columns.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:
- Identify modules needed from the request
- 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 - Select field types using the guide in Section 4 below and
references/field-types.md - Assign variable names using the naming convention rules in Section 5
- Write skip logic for all conditional fields using patterns in Section 6
- Write constraints for all numeric and text fields using patterns in Section 7
- Add calculate fields for totals, IDs, module timing, treatment assignment
- Assemble the 3-sheet XLSForm (survey, choices, settings)
- Run the quality checklist in Section 12 before delivering
- Produce the output in the format requested (see Section 9)
For adding a section or module:
- State where in the form the section belongs (module prefix, group name)
- Produce only the new rows — do not repeat the full form
- Note any dependencies (fields referenced in relevant= or calculation= that must exist in the form)
For debugging:
- Identify the symptom: field not showing, constraint not firing, calculation returning blank, repeat not working, pulldata returning empty
- Check against the debugging patterns in
references/repeats-and-rosters.md(repeat debugging checklist) andreferences/expressions-and-logic.md(syntax rules) - 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
integerfor phone numbers, national IDs, or any number >999,999,999 - Use
calculate_hereonly for module elapsed-time snapshots; never for skip logic - Use
once()around anyrandom()ornow()incalculatefields geopointfields: always setaccuracy_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 applicable99= Other (specify)88= Don't know (forselect_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:
namecolumn values (stay English)calculationexpressionsrelevant=andconstraint=expressionshintinstructions 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
namevalues are ≤32 characters - All
namevalues are globally unique (not just unique within group/repeat) -
begin groupandend groupnames match exactly -
begin repeatandend repeatnames match exactly - No spaces, hyphens, or punctuation in variable names (underscores only)
Types and structure
- Every
select_oneandselect_multiplereferences a list that exists in the choices sheet - Every
begin group/begin repeathas a matchingend group/end repeat -
repeat_countfield isintegertype withrequired: yes -
calculatefields have acalculationvalue; visible fields do not usecalculationunless for dynamic default
Logic
- All
relevant=expressions use${fieldname}(not bare names) - All
constraint=expressions use.for current field value -
once()wraps everyrandom(),now(), andduration()incalculatefields -
selected()always passes choice value as string in single quotes:selected(${field}, '1') -
indexed-repeat()second argument is thenameof thebegin repeatrow -
indexed-repeat()index is guarded withrelevant=${count} >= Nfor each N used
Repeats
- All field names inside repeats are globally unique (not just repeat-local)
- Inner repeat's
repeat_countreferences a field in the current outer instance - No
calculate_hereinside repeat groups
Choices
- Every choice list referenced in
typecolumn exists in choices sheet - Choice
namevalues are integers where possible (Stata compatibility) - Cascading selects: filter column in choices matches
choice_filterexpression column name
Settings
-
form_idhas no spaces -
instance_nameevaluates to a string (usesconcat(),string()for dates) -
versionfollowsYYYYMMDDNNformat
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:
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.