Clinical Note Extraction
Structured extraction from clinical notes against a user-defined schema, with span citations for every value and explicit nulls for every absence. One note or many — the path is the same: an isolated no-tools worker extracts each note, then a deterministic validation pass verifies spans and codes.
This is the extraction primitive that care-gap reasoning, adverse-event detection, trial-eligibility screening, prior-auth evidence assembly, and registry abstraction sit on.
Steps
1 Define schema — references/01-define-schema.md
2 Extract — workflows/extract-batch.js (one isolated worker per note)
3 Validate — span check + run each field's `check`
4 Report — references/03-review.md
Step 1 — Define schema
Read
references/01-define-schema.md
. Turn the user's request into a schema: each field is
.
says what to look for in the note's own terms;
means classify assertion;
is how step 3 validates (open-ended —
{kind: "terminology"|"range"|"date"|"pattern"|"enum"|..., ...params}
). Confirm with the user before extracting.
Step 2 — Extract
However the user supplied notes — pasted text, file paths, a directory, PDFs, a FHIR connector, a database query — resolve each to plain text using whatever tools you have, then call the saved workflow with one
per note. The workflow's input contract is the only strict piece; how you get there is yours to figure out. It runs one
agent per note (no tools — note text is untrusted), each following
, and returns one schema-enforced record per note:
Workflow({
scriptPath: "<this skill dir>/workflows/extract-batch.js",
args: {
notes: [{id, text}, ...], // one or many
schema: <the schema from step 1>,
rules: <Read references/rules.md verbatim>
}
})
Workers have no tools — they return only what they read (
,
,
/
/
,
,
). All checks happen in step 3. Because note text rides inline in
, the workflow path tops out at a few dozen notes per call. For larger corpora, run
bun <this skill dir>/scripts/batch.ts <notes-dir> <schema.json> records.jsonl
instead — it reads files in trusted code and spawns one tool-disabled extraction per note with the same rules, then resume at step 3 over the resulting
.
Step 3 — Validate
Runs here in the calling session. Deterministic — no model judgment. For every record:
- Span check. For every non-null field, confirm appears verbatim in that note's source text. Attach .
- Run each field's . Dispatch on :
- — dedupe across all records, look each up via whatever connector answers to , attach
{code, code_status, display}
. No connector for that → code_status: "unvalidated"
, name it in the report.
- — vs and vs ; attach .
- — confirm parses as a date; attach .
- / — match; attach .
- other / no — nothing to attach.
A field is trustworthy when
and its check (if any) passed. Adding a check kind = add a branch here; nothing upstream changes.
Step 4 — Report
Read
. Produce one row per (note, field):
note_id | field | value | presence/temporality/experiencer | span | check
. Below it, the completion summary: fields requested / populated / null, and per
what passed vs flagged (name any terminology
that lacked a connector). Never let a failed check or unverified span pass silently.
Offer to write records + report to
~/.claude/data/healthcare/clinical-note-extract/<run-id>/
. That directory is local working state, not an archive: do not copy it to shared drives or external systems without the user's explicit instruction, and tell the user it can be deleted once they have what they need — extracted records carry whatever PHI was in the source notes.
Output contract
Worker emits, per field:
{value, span, location, presence?, temporality?, experiencer?, null_reason?, unit?}
— only what it read. Step 3 attaches
plus whatever the field's
produced (
/
/
for terminology,
for range, etc.).
Optional — export as FHIR
If the user wants FHIR resources instead of flat records, the assertion axes map directly:
| record | FHIR |
|---|
| FamilyMemberHistory.condition
(not ) |
→ verificationStatus: refuted
; → ; → | Condition.verificationStatus
|
| → ; → | |
temporality: hypothetical
| no native field — omit, or use a resource |
| + terminology check result | as a ({text: value, coding: [{system, code, display}]}
) |
| + | or a provenance extension |
This is a deterministic transform over the validated records — no model call. Offer it when the user names FHIR as the target; otherwise the flat records are the default.
Prerequisites
Connectors for whatever
values the schema names. Missing ones don't block extraction — those fields stay unvalidated and the report names them.