Medical records
File a medical After Visit Summary into the Obsidian vault as a structured, queryable record. Extracts vitals and visit metadata from the document text, writes a dated note with flat Number frontmatter properties (so Obsidian Bases and Dataview can trend vitals across visits), archives and embeds the source scan, and slots the note into the Medical Vitals base. Usually invoked by the scan-document router when it classifies a scan as an after-visit summary, but can be run directly when the user says 'file this doctor visit', 'capture this after-visit summary', or 'log my visit vitals'. v1 handles after-visit summaries only.From its SKILL.md
npx -y skills add pfeff/claude-skills --skill medical-recordsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
9.1 KB, as published. Nobody here has run it
medical-records — file an After Visit Summary
A handler skill: turns the text of a medical After Visit Summary into a structured vault record. Consumes OCR'd text (from scan-document, or produced here if invoked directly) and the source file; produces a dated note + archived scan.
When this fires
- Invoked by
scan-documentfor classmedical.after-visit-summary. - Direct: "file this doctor visit", "capture this after-visit summary", "log my visit vitals".
Trigger signals (how scan-document recognizes this)
This handler owns its own recognition vocabulary — scan-document's router does not hardcode these; it reads this section to decide whether a scan belongs here. A document is an After Visit Summary when the recognized text shows several of:
- Header/title text: "After Visit Summary"
- An MRN (medical record number)
- Vitals (blood pressure, weight, pulse, temperature, BMI, oxygen saturation, etc.)
- A provider/clinic/institution name
- A follow-up date or discharge instructions
One signal alone may be coincidental (e.g. a phone number that looks like an MRN); several together are a confident match. Ambiguous cases should be confirmed with the user rather than guessed.
Inputs
- Path to the source scan (PDF/image).
- The recognized text. If not supplied (direct invocation), OCR it first:
pdftotext -layout "<file>" -fast path (only if thepdftotextbinary is present — it is not part of stock macOS; treat "command not found" as "skip to Vision," not an error), elseswift <scan-document-dir>/scripts/ocr.swift "<file>".
Command safety: the file path is untrusted input (user- or filesystem-derived). Always pass it as a single quoted argument to any shell command — never interpolate it unquoted — and reject/validate it if it contains shell metacharacters (; | & $ \ ( ) < > newline`) before invocation.
Steps
-
Extract these fields from the text (leave a field out if genuinely absent — do not invent):
- Visit metadata:
visit_date(the appointment date), provider/institution, department, department phone, MRN, reason(s) for visit, referral(s) + expiry, follow-up date, medications. - Vitals: blood pressure (systolic + diastolic separately), weight, BMI, height, pulse, temperature, oxygen saturation.
- Visit metadata:
-
Resolve the vault via the
obsidian-noteshost-config (~/.claude/hosts/<hostname>.md). Bail clearly if none is configured. -
Path safety:
slugandvisit_dateare both derived from OCR'd text and are therefore untrusted — sanitize both before using them to build anyAttachments/orNotes/path. Constrainslugto[a-z0-9-](lowercase, strip/replace anything else, collapse repeats). Validatevisit_dateas strict ISOYYYY-MM-DD; if it doesn't match, treat it as unrecoverable and fall through to the "ambiguous visit date" edge case rather than using the raw value in a path. Never write an unvalidatedslugorvisit_dateinto a filesystem path — this blocks path traversal (../, absolute paths, embedded separators) riding in through OCR text. -
Archive the scan: copy the source file into
<vault>/Attachments/named<visit_date>-<slug>.<ext>(e.g.2026-06-03-osu-sleep-avs.pdf), using the sanitized values from step 3. The attachment is a source artifact — name it by the document's own date for findability. Verify the filename is unique in the vault (the embed resolves by filename). -
YAML safety:
title,provider,department,department_phone, eachvisit_reasonitem,referral, andmedicationsare all derived from OCR'd text and are therefore untrusted — never write any of them into frontmatter unquoted. Emit every OCR-derived scalar as a double-quoted YAML string with internal"escaped as\"and\escaped as\\(or as a YAML block scalar for long/multi-line values). A stray:, a leading-, an embedded newline, or a line that merely resembles---orkey: valuein the source text can otherwise break the YAML frontmatter block or inject extra properties — and medical text is full of colons (times, ratios, dosages). This applies to every scalar in the schema below sourced from OCR text, not just the ones named here. -
Write the note at
<vault>/Notes/<write-year>/<write-month>/<write-date>-<slug>.md(sanitizedslug):- Filename prefix = today's write date, NOT the visit date. The prefix is a de-dupe / folder-partition key; the visit date lives in frontmatter as
visit_date. - Frontmatter — the schema (see below). Vitals are flat, top-level, Number-typed properties. This is deliberate and load-bearing (see "Why this schema").
- Body: visit header, a human-readable vitals table, plan & follow-up, a condensed instructions summary (keep patient-specific guidance; the full text lives in the embedded PDF), and the embedded source (
![[<attachment>]]).
- Filename prefix = today's write date, NOT the visit date. The prefix is a de-dupe / folder-partition key; the visit date lives in frontmatter as
-
Report where the note and attachment landed, and note that it will appear in the Medical Vitals base.
Frontmatter schema
Every OCR-derived scalar below (title, provider, department, department_phone, visit_reason items, referral, medications) MUST be written as a quoted, YAML-safe string — see "YAML safety" in Steps above. Do not write raw OCR text into frontmatter.
---
title: <provider> — After Visit Summary
type: medical-visit
visit_date: 2026-06-03 # semantic date (Date type)
tags:
- medical/visit
- <specialty> # e.g. sleep-medicine
provider: <institution>
department: <department>
department_phone: <phone>
mrn: "<mrn>" # quoted — keep as Text, not a number
visit_reason:
- <reason>
referral: <referral + expiry> # omit if none
follow_up: 2026-12-03 # omit if none
medications: <text or "none prescribed">
source_pdf: "[[<attachment filename>]]"
captured: 2026-07-13 # write date; matches filename prefix
# --- vitals: flat Number properties → Bases columns / Dataview trending ---
bp_systolic: 122
bp_diastolic: 68
weight_lb: 188
bmi: 26.98
height_in: 70
pulse_bpm: 77
temp_f: 97.3
spo2_pct: 99
---
Why this schema (do not "improve" it back to the obvious-but-wrong forms)
Verified against Obsidian's official Properties and Bases docs:
- Vitals are flat top-level properties, not a nested
vitals:object. Obsidian's Bases model is literally "each row is a file, each column is a property of that file," and Bases/Dataview read frontmatter only, never the note body. Nested properties are not supported in the Properties UI. So flat frontmatter is the correct — and only — form that serves both Bases and Dataview. - Blood pressure is split into two Numbers (
bp_systolic/bp_diastolic), not a single122/68string. Obsidian has 6 property types (Text, List, Number, Checkbox, Date, Date&time) and no unit type; a122/68value is Text and can't be trended. Split → both Numbers → aggregatable. - Numbers are bare (no quotes, no units in the value) so they type as Number. Units go in the key (
weight_lb,temp_f) since there is no unit type.mrnis the exception — quote it so it stays Text. - No
vital_prefix.type: medical-visitalready scopes these; clean names make better Base columns.
The Medical Vitals base
Records feed <vault>/Areas/Health/Medical Vitals.base — a table filtered to type == "medical-visit", sorted by visit_date, with the vitals as columns and a bp formula (bp_systolic + "/" + bp_diastolic) for display. It exists after the first record; adding visits just adds rows. If it is missing, recreate it with that shape.
Edge cases
- Missing vitals: omit absent properties rather than writing nulls or zeros — a zero would corrupt trend averages.
- Ambiguous visit date: if the appointment date isn't clearly recoverable, ask (AskUserQuestion) rather than guessing; it is the semantic key.
- Non-AVS medical doc (lab result, imaging, immunization): out of scope for v1 — say so and fall back to the router's generic capture. Those get their own handlers later.
- Height unchanged across visits: fine to carry it each time; it makes each record self-contained.
- Sensitive content: these records carry MRN and vitals — private medical data by nature. Capture into the local vault only; never send content (including the file, extracted text, or any field) to an external service without explicit operator say-so.