DEV Community

mock health
mock health

Posted on • Originally published at mock.health

How to Make Claude Write Valid Synthea Modules

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:

Synthea vs CDC condition prevalence

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.

Conditions per patient by age bracket

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
}
Enter fullscreen mode Exit fullscreen mode

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")'
Enter fullscreen mode Exit fullscreen mode
{ "name": "result", "valueBoolean": true }
{ "name": "display", "valueString": "Coeliac disease" }
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

The skill follows six steps:

  1. Check existing modules — avoids duplicating what's already there
  2. Research the condition — prevalence, diagnostic criteria, treatment
  3. Look up every code — validates against tx.fhir.org before writing
  4. Generate module JSON — following Synthea's exact schema
  5. Build and test./gradlew build -x test + ./run_synthea -m <name> -p 1
  6. 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For Deeper Work

  • /fhir Claude 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)