How to Make Claude Write Valid Synthea Modules

Synthea has 85 disease modules. Need one it doesn't have? LLMs can generate the JSON, but they'll hallucinate the medical codes. Here's a Claude Code skill that grounds every SNOMED, LOINC, and RxNorm code in a real terminology server.

mock.health · 14 min read · 2026-06-01


Passes FHIR validation. Fails vibe check.

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. The results explain why the built-in modules aren't enough:

Synthea vs CDC condition prevalence

Coronary heart disease: 0%. Alzheimer's: 0%. Entire disease categories missing from 10,000 patients. We wrote about the architecture behind this in how we validate synthetic data — the short version is that each module runs independently, so conditions don't interact the way real diseases do.

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 of Synthea's "conditions" are social determinants and administrative codes — Medication review due (situation) appears in 100% of patients. The green dashed line showing the real-world benchmark is barely visible because the scale is that far off.

If your use case needs a disease that Synthea's 85 modules don't cover, or needs more realistic disease modeling than the built-in coin-flip architecture provides, 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 to the next state. Here's the smallest useful module — a condition that gets diagnosed at an encounter:

{
  "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? You need to look it up.

The state types you'll actually use: Initial, Terminal, Encounter/EncounterEnd (start and end clinical visits), ConditionOnset/ConditionEnd (diagnose and resolve), MedicationOrder/MedicationEnd (prescribe and stop), Observation (labs and vitals), Procedure (medical procedures), Guard (wait for a condition to be true), Delay (time passage), and SetAttribute (store patient variables).

Transitions come in four flavors: direct_transition (always go here), conditional_transition (if-then branching), distributed_transition (probability-weighted), and complex_transition (conditions with probability distributions). Real modules use all four.

The Code Problem

Every clinical state in a Synthea module needs codes from standard vocabularies:

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 the codes are usually right — there's enough training signal. For anything less common, the LLM generates a plausible-looking number that might not exist in the code system at all.

The fix: tx.fhir.org, a free public FHIR terminology server maintained by HL7. No account needed. One curl call validates any code:

# Is SNOMED 396331005 a real 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 when you need to find the right code:

# Search SNOMED for "celiac disease"
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}'
{ "code": "396331005", "display": "Coeliac disease" }
{ "code": "473213008", "display": "Celiac disease annual review" }
{ "code": "703970007", "display": "Celiac disease monitoring" }

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. It knows the module JSON schema, the code systems, and the validation endpoints. Install it and point it at a condition:

# Install the skill
claude install github:mock-health/samples/synthea-module-skill

# Use it
claude "/synthea create a celiac disease module"

The skill follows a six-step workflow:

  1. Check existing modules — searches src/main/resources/modules/ to avoid duplicating what Synthea already has
  2. Research the condition — prevalence rates, diagnostic criteria, treatment pathway
  3. Look up every code — validates each SNOMED, LOINC, and RxNorm code against tx.fhir.org before writing it into the module
  4. Generate the module JSON — following Synthea's exact schema with only validated codes
  5. Build and test — runs ./gradlew build -x test for structural validation, then ./run_synthea -m <name> -p 1 to test generation
  6. Inspect output — checks the generated FHIR bundle for expected resource types

The skill's SKILL.md contains the full module schema reference, code system mappings, grounding rules, and common pitfalls. It's the reference documentation the Synthea wiki doesn't have.

Working Example: Celiac Disease

Here's the module we built using this workflow. Every code was validated against tx.fhir.org before it went into the JSON.

Code inventory (all validated):

Concept System Code Display
Celiac disease SNOMED-CT 396331005 Coeliac disease
Encounter for problem SNOMED-CT 185347001 Encounter for problem
EGD SNOMED-CT 76009000 Esophagogastroduodenoscopy
Duodenal biopsy SNOMED-CT 235261009 Biopsy of duodenum
Gluten free diet SNOMED-CT 160671006 Gluten free diet
Diet education SNOMED-CT 11816003 Diet education
Iron deficiency anemia SNOMED-CT 87522002 Iron deficiency anemia
Follow-up encounter SNOMED-CT 390906007 Follow-up encounter
tTG IgA antibody LOINC 31017-7 Tissue transglutaminase IgA Ab [Units/volume] in Serum
Ferritin LOINC 2276-4 Ferritin [Mass/volume] in Serum or Plasma
Ferrous sulfate RxNorm 310325 ferrous sulfate 325 MG (iron 65 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_Delay (2-6 weeks)
        → Endoscopy_Encounter → EGD + Duodenal biopsy
          → Celiac_Diagnosis → Prescribe gluten-free diet
            → 50% chance: Iron deficiency → Prescribe ferrous sulfate
              → Annual monitoring loop (tTG IgA + ferritin)

The module uses complex_transition for age-stratified onset — childhood (higher rate) and ages 30-50 (second peak) get different probabilities. Newly diagnosed patients have a 50% chance of iron deficiency anemia (realistic for celiac). The monitoring loop runs annually with repeat serology and ferritin.

The full module JSON is about 200 lines. Drop it into synthea/src/main/resources/modules/celiac_disease.json and run:

cd synthea
./gradlew build -x test                           # validate structure
./run_synthea -m celiac_disease -p 10 -s 42        # generate 10 patients
jq -r '.entry[].resource.resourceType' output/fhir/*.json | sort | uniq -c | sort -rn

You'll get Encounters, Conditions (celiac disease, iron deficiency anemia), Observations (tTG IgA, ferritin), Procedures (EGD, duodenal biopsy), MedicationRequests (ferrous sulfate), and CarePlans (gluten-free diet).

For Deeper Work

/fhir Claude Code skill — for FHIR development beyond Synthea modules: R4/R5, Implementation Guides, FSH authoring, SMART on FHIR, validation.

Inferno — ONC's FHIR server compliance test suite. If you're testing whether your server conforms to US Core, this is the tool.

Synthea Module Builder — a GUI for visual module authoring. Good for understanding the state machine visually, but doesn't help with code grounding.

tx.fhir.org — the public FHIR terminology server. Free, no account. The skill uses it automatically, but you can query it directly for any code system.

The module format is learnable. The vocabulary problem is solvable. Ground your codes, validate your output, 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 — comorbidity interactions, realistic prevalence, medication variety — requires a different architecture entirely. Free tier, API key in 60 seconds →