DEV Community

Cover image for Building an AI Agent on OpenEMR: What the Docs Don't Tell You
Faheem
Faheem

Posted on

Building an AI Agent on OpenEMR: What the Docs Don't Tell You

OpenEMR is one of the most widely deployed open-source EHR systems in the world, but building an AI agent on top of it involves a handful of non-obvious gotchas that will burn you if you go in blind.

This is a practical guide based on real production experience. Not a tutorial — just the things I wish were documented.


Use FHIR for Clinical Reads, REST for Exceptions

OpenEMR exposes two APIs:

  • FHIR R4 at /apis/default/fhir/
  • Standard REST at /apis/default/api/

The short answer: use FHIR for everything you can. The Standard API mixes numeric pid with UUID puuid depending on the endpoint (medications and vitals use numeric IDs, everything else uses UUIDs). FHIR uses UUIDs consistently.

The two exceptions where you'll need the REST API:

  • Insurance/coverage — the FHIR Coverage mapper is incomplete (missing plan_name, group_number, date_end). Use GET /api/patient/:puuid/insurance instead.
  • Practitioner specialty — FHIR Practitioner doesn't map the specialty field. Use GET /api/practitioner which reads directly from the users table.

OAuth2: Five Things That Will Break You

1. Clients are disabled by default

After OAuth client registration, your client is created with is_enabled=0. Auth fails silently — no clear error message. Fix with SQL after registration:

UPDATE oauth_clients
SET is_enabled=1, grant_types='authorization_code password refresh_token'
WHERE client_id='<your_client_id>';
Enter fullscreen mode Exit fullscreen mode

2. Consider two separate tokens for FHIR and REST

The official docs show api:fhir and api:oemr being registered together, and it works in many setups. In practice we found that separating them — one token per API type — made scope debugging and token renewal much cleaner. If you're only using FHIR, don't worry about it. If you're hitting both APIs, separate tokens are worth considering.

3. user_role=users is required (and not in the OAuth spec)

The password grant requires a non-standard parameter:

grant_type=password
user_role=users   ← required, not standard OAuth2
username=admin
password=...
Enter fullscreen mode Exit fullscreen mode

Omit it and you get a generic auth error.

4. Use user/* scopes, not patient/*

patient/* scopes require a SMART on FHIR launch context. For a backend service agent, always use user/*. And FHIR resource names in scopes are case-sensitive uppercase:

✅ user/Patient.rs
✅ user/MedicationRequest.rs
❌ user/patient.rs   (Standard API format, wrong for FHIR)
Enter fullscreen mode Exit fullscreen mode

5. Registration silently drops some REST scopes

Registering with user/practitioner.read in the scope string? It gets silently dropped during registration. After registering, inject missing scopes via SQL:

UPDATE oauth_clients
SET scope = CONCAT(scope, ' user/practitioner.read')
WHERE client_id='<your_client_id>';
Enter fullscreen mode Exit fullscreen mode

FHIR Bugs to Know About

Don't trust bundle.total

The total field in FHIR search responses is unreliable — _summary=count always returns 0. Always use len(bundle["entry"]) instead.

Practitioner?name= returns 500

Searching practitioners by full name crashes. Use family instead and strip title prefixes ("Dr.", "Doctor") before passing them:

GET /fhir/Practitioner?family=Chen    ✅
GET /fhir/Practitioner?name=Dr.+Chen  ❌  (500)
Enter fullscreen mode Exit fullscreen mode

Appointment?status=free returns nothing

free is a valid FHIR Slot status, but not a valid Appointment status. Querying it returns an empty bundle. To find unbooked appointment slots, fetch all appointments in a date range and detect free slots client-side: a slot with no Patient participant is unbooked.

# A slot is free if it has no patient in participants
is_free = not any(
    "Patient" in p.get("actor", {}).get("reference", "")
    for p in appointment.get("participant", [])
)
Enter fullscreen mode Exit fullscreen mode

_count breaks Appointment search

Passing _count=N to GET /fhir/Appointment returns empty results regardless of data. Leave it out and accept OpenEMR's default page size.

FHIR Appointment write may not work (version-dependent)

On OpenEMR 7.0.x, POST /fhir/Appointment returned 404 in our setup, and write scopes for Appointment were rejected at the token endpoint. The API README lists Appointment create as supported, so this may be fixed in newer versions or specific to certain configurations. If you hit this, inserting directly via SQL into openemr_postcalendar_events is the fallback.

The REST insurance provider field is a numeric FK

GET /api/patient/:puuid/insurance returns a provider field that looks like a company name but is actually a numeric foreign key to another table. Use plan_name for display instead.


One External API Note

If you're planning to use the RxNorm Drug Interaction API for medication safety checks — don't. The interaction check endpoints were discontinued on January 2, 2024. The RxCUI lookup (/rxcui.json) still works, but /interaction/list.json returns 404. Plan for a local curated dataset or DrugBank (paid) instead.


The Architecture That Works

Given all of the above, here's what a clean integration looks like:

FHIRClient
├── _token      → FHIR token (api:fhir scopes, user/Resource.rs)
├── _rest_token → REST token (api:oemr scopes, user/resource.read)
├── get_token() → lazy acquire, re-auth before 1hr expiry (no refresh tokens)
└── Methods
    ├── All clinical reads → FHIR endpoints
    ├── get_insurance()   → REST primary, FHIR fallback
    └── get_providers()   → FHIR search + REST specialty enrichment
Enter fullscreen mode Exit fullscreen mode

No refresh tokens are issued with the password grant. Just re-authenticate before the 1-hour expiry.


Quick Reference: Things Not to Do

  • Don't use Standard API for clinical reads
  • Don't trust bundle.total — check entry array length
  • Don't assume mixed api:oemr + api:fhir scope behavior is consistent across versions
  • Don't use patient/* scopes (need user/* for backend services)
  • Don't use Practitioner?name= (500 error) — use family=
  • Don't query Appointment?status=free — detect free slots client-side
  • Don't pass _count to Appointment search
  • Don't rely on the RxNorm Drug Interaction API (discontinued Jan 2024)
  • Don't forget user_role=users in token requests
  • Don't assume registered scopes are actually saved — verify via SQL

Built by Faheem Syed while integrating a LangGraph-based clinical AI assistant with OpenEMR 7.x.

Top comments (0)