Synthea has 85 disease modules. Each one is a JSON state machine that generates encounters, conditions, labs, medications, and procedures for a specific disease. If you need a condition Synthea doesn't cover — celiac disease, migraine, GERD, whatever — you author a new module.
The module format is learnable. The hard part is the medical codes.
Every module embeds SNOMED codes for conditions, LOINC codes for labs, and RxNorm codes for medications. Ask an LLM to write a celiac disease module and it'll generate SNOMED code 396331005. That's correct. Ask it for a duodenal biopsy and it might generate 12866006. Looks right. Validates as a real SNOMED code. It's actually pneumococcal vaccination.
You can't tell a valid code from a hallucinated one by looking at it. The only way to know is to check it against a terminology server. This post teaches that workflow, and we published a Claude Code skill that automates it.
Why You'd Want Custom Modules
We generated 10,000 patients from a fresh Synthea clone and compared against CDC benchmarks:
Coronary heart disease: 0%. Alzheimer's: 0%. Entire disease categories missing from 10,000 patients. Each module runs independently, so conditions don't interact the way real diseases do. We wrote about this in depth in how we validate synthetic data.
An average 80+ year-old Synthea patient has 74 active conditions. The top 1% of real Medicare patients have about 8. Most are social determinants and administrative codes, not diseases.
If your use case needs a disease that Synthea's 85 modules don't cover, you're authoring a module.
The Module Format in 60 Seconds
A Synthea module is a JSON file with a name, a states object, and a gmf_version. Each state has a type and a transition. Here's the smallest useful module:
{
"name": "Example Condition",
"states": {
"Initial": {
"type": "Initial",
"distributed_transition": [
{ "distribution": 0.01, "transition": "Onset" },
{ "distribution": 0.99, "transition": "Terminal" }
]
},
"Onset": {
"type": "ConditionOnset",
"codes": [{ "system": "SNOMED-CT", "code": "??????", "display": "??????" }],
"direct_transition": "Terminal"
},
"Terminal": { "type": "Terminal" }
},
"gmf_version": 2
}
The ?????? is the problem. What SNOMED code goes there?
State types you'll use: Initial, Terminal, Encounter/EncounterEnd, ConditionOnset/ConditionEnd, MedicationOrder/MedicationEnd, Observation, Procedure, Guard, Delay, SetAttribute.
Transitions: direct_transition (always), conditional_transition (if-then), distributed_transition (probabilistic), complex_transition (conditions + probabilities).
The Code Problem
| System | What it codes | Example |
|---|---|---|
| SNOMED-CT | Conditions, procedures, findings |
396331005 = Celiac disease |
| LOINC | Lab results, vital signs |
31017-7 = tTG IgA antibody |
| RxNorm | Medications |
310325 = Ferrous sulfate 325mg |
LLMs pattern-match these codes from training data. They don't look them up. For common conditions, usually right. For anything less common, the LLM generates a plausible number that might not exist.
The fix: tx.fhir.org, a free public FHIR terminology server. No account needed. One curl call validates any code:
curl -s "https://tx.fhir.org/r4/CodeSystem/\$validate-code?\
system=http://snomed.info/sct&code=396331005" \
| jq '.parameter[] | select(.name=="result" or .name=="display")'
{ "name": "result", "valueBoolean": true }
{ "name": "display", "valueString": "Coeliac disease" }
And to find the right code:
curl -s "https://tx.fhir.org/r4/ValueSet/\$expand?\
url=http://snomed.info/sct?fhir_vs&filter=celiac+disease&count=5" \
| jq '.expansion.contains[] | {code, display}'
This is what separates a working module from one that generates corrupt FHIR.
The Skill
We published a Claude Code skill that automates this workflow:
claude install github:mock-health/samples/synthea-module-skill
claude "/synthea create a celiac disease module"
The skill follows six steps:
- Check existing modules — avoids duplicating what's already there
- Research the condition — prevalence, diagnostic criteria, treatment
- Look up every code — validates against tx.fhir.org before writing
- Generate module JSON — following Synthea's exact schema
-
Build and test —
./gradlew build -x test+./run_synthea -m <name> -p 1 - Inspect output — checks the generated FHIR bundle
The SKILL.md contains the full module schema reference, code system mappings, grounding rules, and common pitfalls.
Working Example: Celiac Disease
Every code validated against tx.fhir.org:
| Concept | System | Code | Display |
|---|---|---|---|
| Celiac disease | SNOMED-CT | 396331005 |
Coeliac disease |
| EGD | SNOMED-CT | 76009000 |
Esophagogastroduodenoscopy |
| Duodenal biopsy | SNOMED-CT | 235261009 |
Biopsy of duodenum |
| Gluten free diet | SNOMED-CT | 160671006 |
Gluten free diet |
| Iron deficiency anemia | SNOMED-CT | 87522002 |
Iron deficiency anemia |
| tTG IgA antibody | LOINC | 31017-7 |
Tissue transglutaminase IgA Ab |
| Ferritin | LOINC | 2276-4 |
Ferritin [Mass/volume] in Serum or Plasma |
| Ferrous sulfate | RxNorm | 310325 |
ferrous sulfate 325 MG Oral Tablet |
The state machine:
Initial → Age_Guard (wait until age 2)
→ Prevalence_Check (monthly, age-stratified probability)
→ Symptom_Onset → Diagnostic_Encounter
→ tTG IgA test → Referral (2-6 weeks)
→ Endoscopy + Duodenal biopsy
→ Celiac_Diagnosis → Gluten-free diet
→ 50%: Iron deficiency → Ferrous sulfate
→ Annual monitoring (tTG IgA + ferritin)
The module uses complex_transition for age-stratified onset — childhood and ages 30-50 get different probabilities. The full module JSON is about 200 lines. Drop it into synthea/src/main/resources/modules/ and run:
cd synthea
./gradlew build -x test
./run_synthea -m celiac_disease -p 10 -s 42
jq -r '.entry[].resource.resourceType' output/fhir/*.json | sort | uniq -c | sort -rn
For Deeper Work
-
/fhirClaude Code skill — FHIR R4/R5, IGs, FSH, SMART on FHIR, validation - Inferno — ONC FHIR server compliance testing
- Synthea Module Builder — GUI for visual module authoring
- tx.fhir.org — public FHIR terminology server (free, no account)
The module format is learnable. The vocabulary problem is solvable. Ground your codes, and don't trust any medical code an LLM generates from memory — including ours. We built mock.health because we hit the limits of module-level fixes. Population-level realism requires a different architecture entirely. Free tier, API key in 60 seconds →



Top comments (0)