Skip to content

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

Searches are appended to the FHIR server's base URL. Codes such as LOINC identify the test precisely.
QuestionFHIR resourceExample search
What is the latest HbA1c?ObservationObservation?patient=p1&code=http://loinc.org|4548-4&_sort=-date&_count=1
What medicines are active?MedicationRequestMedicationRequest?patient=p1&status=active
What conditions are recorded?ConditionCondition?patient=p1&clinical-status=active
Any allergies?AllergyIntoleranceAllergyIntolerance?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.

python
# 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}
Two synthetic patients, three observations and two medication requests.

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.

python
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')]
Continuing from the bundle block: patient-scoped tools with provenance and an audit log.
  • 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.

Authorisation with SMART on FHIR

In real systems, an app or agent should never hold blanket database access. SMART on FHIR, built on OAuth 2.0, lets an app be launched from the record system in the context of one patient and one user, with scopes that state exactly what it may do, for example patient/Observation.rs (read and search this patient's observations).

PrincipleIn practice
Least privilegeRequest only the resource types and actions the task needs
Context from the clinicianThe patient comes from the record the user has open, not from the model's text
Read before writeWriting resources (orders, notes) requires separate scopes and explicit human approval
AuditLog every tool call, its inputs, the resources read and the user on whose behalf it ran

Patient choice must not come from the model

  • If a model can pass any patient identifier to a tool, a typo, a confused conversation or a prompt injection could expose another patient's record. Fix the patient in the tool's scope, as in the code above.

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)

  1. Reproduce the tools and add one for active conditions (Condition resources) and one for allergies.
  2. Add a 'stale' flag when the latest observation is more than 12 months old.
  3. Try to read patient p2's data through the p1 tools and confirm it is impossible.
  4. Plug the tools into the agent loop from the previous lesson and have the scripted model produce a summary with source references.
  5. 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.

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.