Convert a directory of clinical forms (PDFs, HTML pages, screenshots, scanned images, vendor templates, plain text — anything that looks like a patient intake form, PROM, follow-up form, or clinician checklist) into a directory of FHIR R4 Questionnaire JSON resources that follow HL7 SDC (Structured Data Capture) idioms. Use whenever the user has a folder of clinical forms they want represented as FHIR Questionnaires, or asks to "make these into FHIR", "convert this PROM", "turn these intake forms into Questionnaire resources", or similar — even if they don't say "SDC" or "FHIR" explicitly, if the source material is clinical questions / rating scales / intake packets and the desired output is structured form definitions, use this skill.
インストール
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
Convert a directory of clinical forms (PDFs, HTML pages, screenshots, scanned images, vendor templates, plain text — anything that looks like a patient intake form, PROM, follow-up form, or clinician checklist) into a directory of FHIR R4 Questionnaire JSON resources that follow HL7 SDC (Structured Data Capture) idioms. Use whenever the user has a folder of clinical forms they want represented as FHIR Questionnaires, or asks to "make these into FHIR", "convert this PROM", "turn these intake forms into Questionnaire resources", or similar — even if they don't say "SDC" or "FHIR" explicitly, if the source material is clinical questions / rating scales / intake packets and the desired output is structured form definitions, use this skill.
Convert forms → FHIR Questionnaires (with SDC)
What this skill does
Takes a directory of mixed clinical form sources (*.pdf, *.html, *.png, *.txt, screenshots, vendor templates) and produces a directory of validated FHIR R4 Questionnaire JSON resources using HL7 SDC STU4 idioms.
Out of scope: prepopulation, observation extraction, terminology server expansion, and the rest of SDC's runtime-integration surface. The goal is structural and rendering fidelity of the source form, with full provenance back to the artifact it came from. Workflow integration is a separate problem.
Scale-likeness fallback for PROMs that lack demographic markers: 0 = … / 1 = … / not at all / several days / nearly every day / total score / circle one / strongly agree/disagree PLUS a medical-context noun (symptom|disease|disorder|pain|depression|anxiety|sleep|arthritis) PLUS score|scale|questionnaire|index|inventory|assessment|measure in the document.
Write your triage decision per input into forms-out/triage.tsv so the user can see what was skipped and why.
2. Author one Questionnaire at a time
For each input bucketed as a form, write a Bun TypeScript builder script that imports scripts/lib.ts (bundled with this skill) and emits the Questionnaire JSON. Per-file scripts (instead of one big script) make the work composable, reviewable, and re-runnable.
Pattern: scripts/build/<id>.ts:
import { questionnaire, group, display, totalScore, ordinal, variable, calcExpr, EXT, SYS } from'../lib';
import { writeFileSync } from'node:fs';
constID = 'phq-9';
const opts = [
{ extension: [ordinal(0)], valueCoding: { system: SYS.loinc, code: 'LA6568-5', display: 'Not at all' } },
{ extension: [ordinal(1)], valueCoding: { system: SYS.loinc, code: 'LA6569-3', display: 'Several days' } },
{ extension: [ordinal(2)], valueCoding: { system: SYS.loinc, code: 'LA6570-1', display: 'More than half the days' } },
{ extension: [ordinal(3)], valueCoding: { system: SYS.loinc, code: 'LA6571-9', display: 'Nearly every day' } },
];
const items = [
['q1', 'Little interest or pleasure in doing things'],
['q2', 'Feeling down, depressed, or hopeless'],
// ...
];
const q = questionnaire({
id: ID, name: 'PHQ9', title: 'Patient Health Questionnaire (PHQ-9)',
derivedFrom: ['http://loinc.org/q/44249-1'],
extension: [variable('phq9Sum',
`%resource.item.descendants().where(linkId.startsWith('phq9.matrix.')).answer.valueCoding.extension.where(url='${EXT.ordinalValue}').valueDecimal.sum()`)],
item: [
display('phq9.instructions', 'Over the last 2 weeks, how often have you been bothered by any of the following problems?', { category: 'instructions' }),
group('phq9.matrix', 'PHQ-9 items',
items.map(([slug, text]) => ({ linkId: `phq9.matrix.${slug}`, type: 'choice', required: true, text, answerOption: opts })),
{ control: 'gtable' }),
totalScore('phq9.totalScore', 'PHQ-9 total score', 'phq9Sum', '44261-6'),
],
source: { sourceUrl: 'https://example.org/phq9.pdf', specialty: 'mental-health', formType: 'prom', host: 'example.org', sha256: '...' },
});
writeFileSync(`forms-out/questionnaires/${ID}.json`, JSON.stringify(q, null, 2) + '\n');
Run with bun scripts/build/phq-9.ts.
The library is intentionally thin — about 150 lines of plain functions that emit FHIR-shaped JSON. There's no proprietary runtime; the output is plain Questionnaire JSON that any FHIR tool can consume.
3. The matrix idiom — most PROMs reduce to this
If the source is a grid of "rate each symptom 0–4" items sharing one answer set, use:
group { itemControl: gtable, repeats: false }
├─ item { type: choice, answerOption: [ordinalValue + valueCoding, ...] }
├─ item { ... same shape ... }
└─ ...
+ a top-level `variable` extension that sums ordinals via FHIRPath
+ a readOnly integer/decimal item with `calculatedExpression` referencing the variable
The FHIRPath sum follows this template (substitute your linkId prefix):
This one recipe covers: PHQ-9, GAD-7, AUDIT, AUDIT-C, KOOS, WOMAC, FIQR, NICHQ Vanderbilt (×4 variants), ISI, ESS, FLACC, AIMS, BPI-SF, MDQ, ASRS, MAHC-10, PAID-20, SF-MPQ, RAPID3, and most other matrix-shaped scales. Items vary; structure doesn't.
4. Intake-packet and checklist patterns
Intake packets are longer (50–200 items typical) but mechanically simpler — flat groups of demographics, insurance, providers, medications, allergies, surgical history, family history, ROS checkboxes, social history, and a free-text chief complaint. Use:
A top-level group per section, with text set to the section heading.
Standard FHIR item types: string (single line), text (paragraph), date, integer, quantity (with unit extension), boolean, choice / open-choice.
Repeating lists (medications, allergies, surgical history) → nested group with repeats: true plus minOccurs/maxOccurs extensions if there's a limit.
For "check all that apply" multi-select → open-choice with answerOption.
Clinician checklists (e.g., AAFP preventive screening): one nested group per intervention with status/date/notes sub-items — same shape repeated. Use the matrix idiom with gtable control if the renderer needs a tabular layout.
Authoring conventions
Skeleton
Every Questionnaire emitted by this skill has:
Field
Value
resourceType
"Questionnaire"
id
kebab-case slug (phq-9, bwh-neurosurgery-intake)
url
canonical, e.g. http://hobby.intake-forms/fhir/Questionnaire/phq-9 — change BASE in lib.ts to your publisher domain
version
"1.0.0" (bump on revisions)
name
machine-readable identifier (no spaces)
title
human-readable title
status
"draft" until the conversion is reviewed; "active" after
experimental
true for research collections; false for production
subjectType
["Patient"] (use ["RelatedPerson"] or ["Patient","Practitioner"] if the form is observer-administered or clinician-completed)
date
conversion date
publisher
your org or collection name
derivedFrom
when there's a LOINC question-set canonical, cite it: ["http://loinc.org/q/44249-1"] for PHQ-9, ["http://loinc.org/q/69737-5"] for GAD-7, etc.
copyright
source instrument's copyright/license text, copied verbatim from the source
linkId conventions
Hierarchical, dot-separated, scoped under the Questionnaire id: phq9.matrix.q1, bwhneuro.demographics.lastName.
Stable: linkIds become foreign keys for QuestionnaireResponses and FHIRPath expressions. Don't rename casually after publishing.
Unique across the entire tree, not just sibling-relative — the local validator enforces this.
Item types
Type
Use for
display
preamble text, instructions, "see your provider" safety blocks. No answer.
group
section heading; contains nested item[]. No answer.
string
single-line free text (name, occupation)
text
multi-line free text (chief complaint, narrative)
boolean
yes/no (use choice with Y/N CodeSystem if you want ordinalValue)
integer, decimal
counts, scores, slider values
quantity
height/weight/BMI/BP — pair with unit / unitOption extensions
date, dateTime, time
per FHIR
choice
single-select from answerOption or answerValueSet
open-choice
multi-select or "other (specify)"
attachment, reference, url
per FHIR, rarely used in intake forms
Provenance (always populate)
Every Questionnaire must carry meta so a consumer can trace it back to the source artifact. The bundled buildMeta() in lib.ts does this from a source argument:
"meta":{"source":"https://example-clinic.org/forms/intake-2024.pdf","tag":[{"system":"http://hobby.intake-forms/fhir/CodeSystem/specialty","code":"ortho-knee"},{"system":"http://hobby.intake-forms/fhir/CodeSystem/form-type","code":"prom"},{"system":"http://hobby.intake-forms/fhir/CodeSystem/source-host","code":"example-clinic.org"}],"extension":[{"url":"http://hobby.intake-forms/fhir/StructureDefinition/derivedFromArtifact","valueString":"raw/pdf/example-clinic-intake-2024.pdf"},{"url":"http://hobby.intake-forms/fhir/StructureDefinition/sourceSha256","valueString":"<sha256 of the source file>"},{"url":"http://hobby.intake-forms/fhir/StructureDefinition/alsoSeenAt","valueString":"raw/pdf/other-site-copy.pdf"}]}
meta.source is core FHIR (the URL the resource originated from). meta.tag carries facets the consumer can filter on. meta.extension carries the source-file hash for dedup, the local artifact path, and any alsoSeenAt entries when the same instrument is republished by multiple sites (very common for standardized PROMs).
This skill is for structural conversion, not workflow integration. The following extensions belong to SDC's prepopulation / extraction / adaptive surface and must be left out — both because they're out of scope and because including them silently changes how downstream tools handle the artifact:
Extension
Why excluded
sdc-questionnaire-launchContext
declares prepopulation inputs
sdc-questionnaire-initialExpression
prefills an item from FHIR data
sdc-questionnaire-candidateExpression
answer candidates from FHIR data
sdc-questionnaire-contextExpression
runtime context for expressions
sdc-questionnaire-itemPopulationContext
per-item prepopulation scope
sdc-questionnaire-sourceQueries
embedded FHIR query for population
sdc-questionnaire-itemExtractionContext
extract Observations from response
sdc-questionnaire-observationExtract
per-item extract-as-Observation flag
sdc-questionnaire-observationLinkPeriod
window for extraction
sdc-questionnaire-endpoint
server endpoint for runtime calls
sdc-questionnaire-entryMode
sequential/random rendering mode
sdc-questionnaire-lookupQuestionnaire
reference another Questionnaire
sdc-questionnaire-answerExpression
dynamic answer options
sdc-questionnaire-answerOptionsToggleExpression
dynamic option toggling
Adaptive $next-question operation
adaptive form delivery
The bundled validate.ts will flag any of these if they sneak in.
Choices: answerOption vs answerValueSet
Inline answerOption[] when answers are small, stable, and you want the artifact to be portable offline (no terminology server needed).
answerValueSet (canonical URL, e.g. http://loinc.org/vs/LL358-3) when answers come from a published value set and a terminology server is available.
Always attach ordinalValue extension on each option for any scored instrument — valueCoding alone is not enough for FHIRPath summation.
Use optionExclusive: true on the "None of the above" option in multi-select lists.
Base R4 enableWhen[] array is fine for simple "show Q5 if Q1=yes" branching. Multiple entries in the array are AND'd; use enableBehavior: "any" for OR.
For complex conditions ("show item if any of Q1–Q9 > 0"), use the SDC enableWhenExpression extension with a single FHIRPath expression — much cleaner than 9 OR'd enableWhen entries.
For computed totals/subscales, use a top-level variable extension defining the FHIRPath expression once, then reference it from a readOnly: trueinteger or decimal item via calculatedExpression (%variableName). This gives the renderer one expression to evaluate and one place to display the result.
Spec quirks (real ones — call out in code comments if you hit them)
ordinalValue (R4) vs itemWeight (R5): R5 renamed the extension. R4 forms keep using ordinalValue at http://hl7.org/fhir/StructureDefinition/ordinalValue. Don't mix.
regex "superseded" by targetConstraint per SDC STU4 prose, but regex is still the registered core extension with broader tool support. Keep regex on string items; use targetConstraint only for cross-item rules.
LOINC's official PHQ-9 example uses cqf-expression with CQL ("language": "text/cql"). For hand-authored forms without a CQL library to reference, use calculatedExpression with FHIRPath everywhere.
answerValueSet requires a terminology server to expand at render time. Inline answerOption doesn't.
linkId unique across the entire item tree (not just sibling-relative)
Every enableWhen.question resolves to an existing linkId
answerOption items match parent item type (no valueInteger on a choice with valueCoding)
No banned SDC extensions from the out-of-scope list above
All item type values are valid
Run this on every file save during authoring. It catches the most common mistakes (template-string typos that make JSON unparseable, banned extensions added reflexively) before they propagate.
Step 2: official HL7 validator (slower, more thorough)
# one-time setup in your workspace (not in the skill dir):
bun add fhir-validator-wrapper
# tell the validator which extension URL prefix is "ours" — provenance# extensions you defined yourself that won't have a public StructureDefinition:export OWN_EXT_PREFIX="http://my-publisher-domain/fhir/StructureDefinition/"
bun scripts/fhir-validator.ts forms-out/questionnaires/
This uses the fhir-validator-wrapper npm package — a Node wrapper that runs the official validator JAR as a long-lived HTTP service. First run downloads ~180 MB of JAR + IG packages (about 90 seconds); subsequent files in the same batch validate in milliseconds because the JVM stays warm and the IGs stay loaded.
Critical performance note: the cold start (JVM boot + IG load) is the long pole — typically 2–5 minutes on a fresh process; per-file validation after that is milliseconds. Batch every validation into one bun fhir-validator.ts invocation whenever possible. If you're iterating on fixes, prefer running the script against the entire directory once (let it report all errors), patching everything, then a single re-validation — not repeated single-file runs that each pay the cold-start tax. If you know you'll want both structural and terminology checks, enable txServer: 'http://tx.fhir.org/r4' in the same invocation; don't split them across two cold starts.
Requires java (any recent JRE). Configurable via env vars:
Env var
Default
Meaning
FHIR_VALIDATOR_JAR_PATH
./validator_cli.jar
where to cache/download the JAR
FHIR_VERSION
4.0.1
FHIR version to validate against
FHIR_IGS
hl7.fhir.uv.sdc#3.0.0
comma-separated IG packages to load
FHIR_VALIDATOR_PORT
8081
localhost port for the validator HTTP service
OWN_EXT_PREFIX
(empty)
URL prefix for your own provenance extensions; "extension URL ... could not be found" errors with this prefix are suppressed
Common findings to expect and how to interpret:
Finding
Meaning
error: extension http://your-domain/... could not be found so is not allowed here
The validator doesn't have a StructureDefinition for your custom extension. Set OWN_EXT_PREFIX to suppress — the script labels these "own-ext ignored" in the summary.
error: extension http://hl7.org/.../questionnaire-minOccurs ... not allowed to be used at this point
The R4 minOccurs/maxOccurs extensions can only sit on items with required: true (or required itself extended). Either make the item required or drop the extension. This is a real R4-vs-R5 constraint that the local validator doesn't catch.
warning: Unable to validate code "LA6568-5" — Resolved system http://loinc.org, but not retrieved
The validator skipped the LOINC lookup because the terminology server is off (tx n/a). Safe to ignore for local validation; flip txServer in scripts/fhir-validator.ts to http://tx.fhir.org/r4 if you want full terminology checks (slower, requires network).
error: duplicate linkId X
structural — fix immediately. (The local validator already catches this.)
error: answerOption type does not match item type
structural — fix immediately.
error: missing required field X
fix immediately.
information: best practice ...
review case-by-case.
The wrapper's HTTP-service approach is the right pattern for batch validation. The two-step flow (local sanity check first, official validator second) lets the fast checker catch the trivial mistakes in your tight authoring loop and the slow-but-thorough validator catch the spec-conformance issues at end of conversion.
Step 3: semantic LOINC audit (the validator can't catch this)
The validator confirms that every (system, code) pair exists in LOINC and that the display matches the authoritative one. It cannot tell you that you've attached a valid LOINC code to the wrong concept — e.g., putting a "Total score [AUDIT-C]" code on the Questionnaire root, putting a 4-week-recall variant on a 2-week instrument, or hallucinating a code (72091-2) whose display happens to share one word with the instrument you meant ("Osteoarthritis" in both WOMAC and KOOS). All of these pass the validator but are semantically wrong and will mislead any downstream consumer.
After the validator is green, dump every remaining LOINC binding and read the table by eye:
# Emits TSV: code, file, linkId, our-context-text, LOINC official display
bun your-audit-script.ts > /tmp/loinc-audit.tsv
column -t -s$'\t' /tmp/loinc-audit.tsv | less -S
Look for these patterns:
Form-level vs total-score code on the Questionnaire root: Total score [X] codes belong on the totalScore item, not the Questionnaire.code[]. Use the form-level code (e.g. 71354-5 "Edinburgh Postnatal Depression Scale [EPDS]") at the root.
Wrong recall window: a code labeled ... in last 4 weeks [Reported.PHQ] on a GAD-7 item (which uses 2-week recall) is wrong even though it's a valid code.
Hallucinated codes that happen to validate: if the authoritative display is completely unrelated to the surrounding question text, the code is wrong even if it exists. Strip it.
Items on instruments LOINC doesn't have codes for: CAGE individual items don't have LOINC codes (only 89001-2 for the CAGE antibody lab test). If you've bound something that looks plausible (e.g. SAMHSA codes), strip it — better no code than wrong code.
A useful shortcut: for any standardized instrument that has a [PROM_NAME] panel/total LOINC, also confirm what code is meant for the form-level binding vs. the score binding. They are almost always two different codes.
Step 4: re-stamp provenance
If you rebuild Questionnaires after editing builders, re-run any provenance attribution step you have (the canonical URL, meta.source, meta.tag, meta.extension[sha256]) to make sure metadata stays consistent.
Checklist before declaring conversion done
For each Questionnaire:
id, url, name, title, status, experimental, subjectType, date, publisher all set
derivedFrom cites LOINC canonical when one exists (standardized instruments only)
copyright quotes the source instrument's terms verbatim
meta.source points to the source URL; meta.tag carries specialty/form-type/host; meta.extension carries sha256 + local artifact path
linkIds are stable, hierarchical, and unique
Every PROM symptom-row option has ordinalValue
Computed totals use top-level variable + calculatedExpression (FHIRPath)
No extensions from the out-of-scope list above
bun scripts/validate.ts passes
bash scripts/fhir-validator.sh passes with no errors (warnings acceptable per the table)
scripts/validate.ts — local sanity checker. Runs in milliseconds; suitable for tight authoring loops. No dependencies.
scripts/fhir-validator.ts — wrapper around the official HL7 FHIR validator using the fhir-validator-wrapper npm package. Spawns the validator JAR as a long-lived HTTP service (warm JVM = fast batches). Requires bun add fhir-validator-wrapper in your workspace, plus java.
When to ask the user
Before starting a large batch, confirm:
The canonical URL base (BASE in lib.ts). Default is http://hobby.intake-forms/fhir; production deployments need their own publisher domain.
status — draft (default; for review) or active (already vetted)?
Triage edge cases — when you find PDFs that look like forms but might be research papers / scoring guides / education handouts, list them and ask before skipping.
Source instrument copyright — for any standardized PROM, confirm the user has the right to host/redistribute it. Some PROMs (KCCQ, MIDAS, EORTC) require licensing for commercial use.
Reasons behind the conventions
A few of the choices in this skill have non-obvious reasons worth keeping in mind:
Per-form builder scripts beat hand-authoring JSON or one giant generator. A 200-line PHQ-9 JSON becomes 30 lines of declarative TypeScript. A 73-item Stanford Ortho intake becomes 150 lines. The output is the same plain Questionnaire JSON, but the builder is small, reviewable, and re-runnable when you find a bug.
Validation runs on every save, not at the end. A 50ms local check catches template-string typos and banned extensions before they propagate across dozens of files. The official HL7 validator is the second pass, not the first.
Provenance is designed in from the start, not bolted on later.meta.source + meta.tag + the source-hash extension let a consumer trace any Questionnaire back to a specific PDF/URL/sha. This becomes essential when you have multiple versions of the same instrument (e.g., 10 different sites publishing AUDIT-C) or when a clinic revises its intake quarterly.
The "do not use" SDC list is as important as the "do use" list. SDC has gravity — start reading the spec and you'll be tempted to add launchContext "just in case". Each excluded extension changes runtime semantics; leaving them out keeps the artifact portable across renderers and use cases.
Standardized instruments are republished constantly across the web (10+ sites posting the same AUDIT-C, 7+ sites posting PHQ-9). When converting a batch, dedup via the meta.extension[alsoSeenAt] pattern rather than authoring 10 near-identical Questionnaires.