Medical AI · Agentic AI · Lesson 2 of 6
Tool calling against FHIR data
Reviewed by Dr. Ram Kumar G, MBBS, MD (Internal Medicine), MPH (USA), on · Last updated
- Intermediate
- Technical focus
- About 30 minutes
- Includes exercise
Before this lesson: Agents and tool use, Data standards: FHIR, HL7, ICD, SNOMED CT, LOINC and OMOP
In short
FHIR gives agents a standard way to read health records: resources such as Observation and MedicationRequest, searched through web APIs. Safe agent tools are narrow (one question each), read-only by default, scoped to the patient the clinician has open rather than chosen by the model, return values with their source resource and date, and log every call. Authorisation frameworks such as SMART on FHIR limit what an app can see in the first place.
You will learn to
- Map common clinical questions to FHIR resources and searches
- Write narrow, read-only, patient-scoped FHIR tools in Python
- Return results with provenance and handle missing data explicitly
- Explain how SMART on FHIR scopes limit an app's access
From clinical questions to FHIR searches
| Question | FHIR resource | Example search |
|---|---|---|
| What is the latest HbA1c? | Observation | Observation?patient=p1&code=http://loinc.org|4548-4&_sort=-date&_count=1 |
| What medicines are active? | MedicationRequest | MedicationRequest?patient=p1&status=active |
| What conditions are recorded? | Condition | Condition?patient=p1&clinical-status=active |
| Any allergies? | AllergyIntolerance | AllergyIntolerance?patient=p1 |
A synthetic FHIR bundle
To run offline, this lesson uses a small in-memory bundle in place of a FHIR server. The same tools could call a real server's search API, such as a public test server loaded with synthetic data.
# A small synthetic FHIR R4 bundle (made-up patients, no real data)
bundle = {"resourceType": "Bundle", "entry": [{"resource": r} for r in [
{"resourceType": "Patient", "id": "p1", "birthDate": "1958-04-02", "gender": "female"},
{"resourceType": "Patient", "id": "p2", "birthDate": "1971-09-15", "gender": "male"},
{"resourceType": "Observation", "id": "o1", "subject": {"reference": "Patient/p1"},
"code": {"coding": [{"system": "http://loinc.org", "code": "4548-4", "display": "Hemoglobin A1c"}]},
"effectiveDateTime": "2026-01-10", "valueQuantity": {"value": 8.2, "unit": "%"}},
{"resourceType": "Observation", "id": "o2", "subject": {"reference": "Patient/p1"},
"code": {"coding": [{"system": "http://loinc.org", "code": "4548-4", "display": "Hemoglobin A1c"}]},
"effectiveDateTime": "2026-07-02", "valueQuantity": {"value": 7.4, "unit": "%"}},
{"resourceType": "Observation", "id": "o3", "subject": {"reference": "Patient/p1"},
"code": {"coding": [{"system": "http://loinc.org", "code": "98979-8", "display": "eGFR (CKD-EPI 2021)"}]},
"effectiveDateTime": "2026-07-02", "valueQuantity": {"value": 41, "unit": "mL/min/1.73m2"}},
{"resourceType": "MedicationRequest", "id": "m1", "status": "active", "subject": {"reference": "Patient/p1"},
"medicationCodeableConcept": {"text": "Metformin 500 mg tablets"}, "authoredOn": "2024-05-01"},
{"resourceType": "MedicationRequest", "id": "m2", "status": "stopped", "subject": {"reference": "Patient/p1"},
"medicationCodeableConcept": {"text": "Gliclazide 80 mg tablets"}, "authoredOn": "2023-02-11"},
]]}
resources = [e["resource"] for e in bundle["entry"]]
print({t: sum(r["resourceType"] == t for r in resources)
for t in ["Patient", "Observation", "MedicationRequest"]})
# {'Patient': 2, 'Observation': 3, 'MedicationRequest': 2}Narrow, read-only, patient-scoped tools
The tools below answer one kind of question each. They are created for a single patient, the one the clinician has open, so the model cannot choose to look at anyone else. Every call is logged.
class PatientTools:
"""Read-only tools scoped to ONE patient, chosen by the clinician, not by the model."""
def __init__(self, resources, patient_id):
self.patient_ref = f"Patient/{patient_id}"
self.resources = resources
self.audit = []
def _mine(self, resource_type):
return [r for r in self.resources if r["resourceType"] == resource_type
and r.get("subject", {}).get("reference") == self.patient_ref]
def latest_observation(self, loinc_code):
self.audit.append(("latest_observation", loinc_code))
matches = [o for o in self._mine("Observation")
if any(c["code"] == loinc_code for c in o["code"]["coding"])]
if not matches:
return {"result": None, "note": f"no observation with LOINC {loinc_code}"}
o = max(matches, key=lambda o: o["effectiveDateTime"])
return {"value": o["valueQuantity"]["value"], "unit": o["valueQuantity"]["unit"],
"date": o["effectiveDateTime"], "source": f"Observation/{o['id']}"}
def active_medications(self):
self.audit.append(("active_medications", None))
return [{"medication": m["medicationCodeableConcept"]["text"],
"source": f"MedicationRequest/{m['id']}"}
for m in self._mine("MedicationRequest") if m["status"] == "active"]
tools = PatientTools(resources, patient_id="p1")
print(tools.latest_observation("4548-4"))
print(tools.latest_observation("98979-8"))
print(tools.active_medications())
print(tools.latest_observation("2093-3"))
print("audit log:", tools.audit)
# {'value': 7.4, 'unit': '%', 'date': '2026-07-02', 'source': 'Observation/o2'}
# {'value': 41, 'unit': 'mL/min/1.73m2', 'date': '2026-07-02', 'source': 'Observation/o3'}
# [{'medication': 'Metformin 500 mg tablets', 'source': 'MedicationRequest/m1'}]
# {'result': None, 'note': 'no observation with LOINC 2093-3'}
# audit log: [('latest_observation', '4548-4'), ('latest_observation', '98979-8'), ('active_medications', None), ('latest_observation', '2093-3')]- The latest HbA1c (7.4%, July) is returned, not the older 8.2%, with its date and source resource, so any statement built on it can be traced.
- Stopped medicines are excluded; only the active metformin is returned. Status handling is a common source of agent errors.
- A missing test (total cholesterol, LOINC 2093-3) returns an explicit 'no observation' rather than an empty value the model might fill in.
- This synthetic patient is on metformin with an eGFR of 41: exactly the kind of combination an agent could surface for a clinician to review.
Designing tool outputs
- Return small, structured results with units, dates and source references, not whole resources.
- Say explicitly when data are missing, out of date or conflicting.
- Normalise units at the tool, using standards such as UCUM, so the model never converts them.
- Keep free-text fields from records clearly marked as untrusted content, because they may contain instructions (prompt injection).
Teaching code, not a clinical tool
- The agents in this track use synthetic patients and a scripted stand-in for the language model, so they run without an API and without real data.
- Agents that can read or change real health records need organisational approval, security review, access control and, where they influence care, regulatory assessment.
Exercise
Build and test patient-scoped FHIR tools over synthetic data, then connect them to the agent loop.
Data: Synthetic FHIR bundle in the notebook; optional Synthea FHIR R4 bundles (Generated in the notebook; Synthea is Apache 2.0)
- Reproduce the tools and add one for active conditions (Condition resources) and one for allergies.
- Add a 'stale' flag when the latest observation is more than 12 months old.
- Try to read patient p2's data through the p1 tools and confirm it is impossible.
- Plug the tools into the agent loop from the previous lesson and have the scripted model produce a summary with source references.
- Optional: load a Synthea FHIR bundle and run the same tools on it.
The runnable notebook and worked solution will be published soon. You can work through the steps with the code on this page in the meantime.
Key takeaways
- FHIR resources and searches give agents a standard way to read records.
- Make tools narrow, read-only and scoped to the patient the clinician has open.
- Return values with units, dates and sources; state missing data explicitly.
- Use SMART on FHIR scopes and log every call.
Terms used: FHIR (Fast Healthcare Interoperability Resources), Least privilege, LOINC, SMART on FHIR, Tool calling
Related: Agents and tool use, Data standards: FHIR, ICD, SNOMED CT, LOINC and OMOP, Multi-agent clinical workflows
FAQ
Can an AI agent connect directly to an EHR?
Technically yes, through FHIR APIs, but only through an approved integration with proper authorisation (for example SMART on FHIR), security review and audit, and usually read-only.
What is SMART on FHIR?
A standard for launching apps from health record systems using OAuth 2.0, so that an app gets access only to a specific patient and user context with defined scopes.
Why scope tools to one patient?
So that the model cannot read another patient's record through a mistaken or manipulated identifier. The clinician's context, not the model's text, decides whose data is accessed.
Should agent tools return whole FHIR resources?
Usually not. Small, structured results with units, dates and source references are easier for the model to use correctly and easier to audit.
Can agents write to the record?
Only with separate permissions and explicit clinician approval of each action. Drafts for review are a safer pattern than direct writes.
Sources
- HL7 International. FHIR R4: RESTful API and search
- HL7 International. SMART App Launch implementation guide
- Mandel JC, et al. SMART on FHIR: a standards-based, interoperable apps platform for electronic health records. J Am Med Inform Assoc 2016;23(5):899–908. doi:10.1093/jamia/ocv189
- Jiang Y, et al. MedAgentBench: a virtual EHR environment to benchmark medical LLM agents. NEJM AI 2025. doi:10.1056/AIdbp2500144
- Regenstrief Institute. LOINC 98979-8: eGFR by CKD-EPI 2021 creatinine equation
Educational content only. This material teaches methods; it is not clinical advice. Code, models and examples here are not validated medical devices and must not be used to make decisions about real patients without proper clinical validation, governance and any required regulatory approval. Examples use synthetic or openly licensed de-identified data only. Never paste identifiable patient information into public AI tools. Mentions of models, tools or vendors are for identification, not endorsement.