Writing AI Notes Back to Epic: FHIR on the Outside, Vendor-Specific on the Inside
Writing AI scribe notes back to Epic, Oracle and eCW via FHIR DocumentReference: payloads, silent failures, audit trails, and why the model never signs.
Mostly about building products, healthcare tech, and lessons learned along the way.
Writing AI scribe notes back to Epic, Oracle and eCW via FHIR DocumentReference: payloads, silent failures, audit trails, and why the model never signs.
Our worst AI scribe write-back bug told a clinician "Written to eClinicalWorks" when nothing had been written. The EHR returned HTTP 200 and dropped the note. Nobody demos this part of ambient documentation: getting the note into the chart under a clinician's name, without losing it, and without reporting a success that didn't happen.
In MedScrub we write AI-generated notes back to Epic, Oracle Health and eClinicalWorks. I use FHIR DocumentReference every time, because all three expose it for notes. The catch is that it means something a little different at each vendor. It's FHIR on the outside and vendor-specific on the inside.
context.period. It's the only one of the three that lets us PUT an amendment.application/pdf is the only attachment type it accepts. So we render the AI draft to a PDF on our side. Landing text inside a progress-note section like HPI takes a separate path: an HL7 v2 message, base64-encoded, sitting inside the DocumentReference.One rule matters more than any of the vendor detail: save the note to your own FHIR repository before you touch the EHR. We write to our clinical data repository first, then to the EHR. If the EHR write fails, the clinician's approved note isn't lost and we can retry it. Losing a note a clinician already reviewed is the fastest way to lose that clinician.
eSpiral, our residency-education product, goes the other way on purpose. It uses read-only FHIR scopes and never writes to the EHR. If your product doesn't need write-back, don't ask for it. Every write scope is one more thing a health system's security team has to approve.
The clinician signs in our app first. Only then do we POST. Here is the minimal shape of the payload, built from the R4 DocumentReference spec:
{
"resourceType": "DocumentReference",
"status": "current",
"docStatus": "final",
"type": {
"coding": [{ "system": "http://loinc.org", "code": "11506-3", "display": "Progress note" }]
},
"subject": { "reference": "Patient/{patient-id}" },
"context": { "encounter": [{ "reference": "Encounter/{encounter-id}" }] },
"content": [
{ "attachment": { "contentType": "text/plain", "data": "<base64 note text>" } }
]
}
Check the required fields on Epic's own spec page for your version before you ship this. And treat the type coding as a starting point, not an answer. More on that below.
One Epic-specific gotcha: Epic's November 2025 change means DocumentReference and Binary IDs can arrive in two formats. Old-format IDs can run past 64 characters. New-format IDs stay within 64. If you store note IDs in a VARCHAR(64) column or validate ID length, old-format IDs will break it.
Here's what I haven't confirmed on Epic. I don't know whether Epic's FHIR API exposes a pended or unsigned note state, and I don't know whether it has a FHIR addendum path. Our design doesn't depend on either one, and that's deliberate.
The tempting design is to write the draft into the EHR as preliminary and let the clinician sign it there. FHIR R4 allows it. docStatus can be preliminary, final, amended or entered-in-error.
That breaks down across vendors. Oracle's R4 create accepts only final for provider access, and final or amended for system access. You can't keep a FHIR-native pended draft there. On Epic, as above, I haven't confirmed that one exists.
So keep the draft in your app, and only send the EHR notes a clinician has signed. In MedScrub's review screen, the "Sign & write" button stays locked until the clinician ticks "I have reviewed this entry." It's also hard-blocked when the model's self-reported confidence is below threshold, or when any de-identification token is still in the text. And it's disabled when the proxy would reject the write. It fails closed, so a clinician is never asked to sign a note the proxy already knows it will reject.
FHIR already draws this line. In R4, DocumentReference.author can be a Device. authenticator can only be a Practitioner, PractitionerRole or Organization. A model can be named as an author, but it can't authenticate.
There's a backdoor, though. With system access, Oracle lets you set authenticator to any valid practitioner. A backend could file a note as authenticated with no clinician doing anything in that session. With provider access, Oracle forbids authenticator and forces the author to be the logged-in provider. If you're on system access, your own gate is the only thing between the model and a signed note.
The spec says type should be "LOINC if possible." Our generic path defaults to LOINC 11506-3, Progress note. Our eCW PDF path uses 34133-9, Summary of episode note, because that's what eCW's own sample uses. Match the vendor's sample.
Oracle accepts LOINC or its proprietary Code Set 72, but not both in one request. Epic tells developers to "work with your customers to understand the note types their users create" (Epic documentation). It's the same lesson I learned with CDS Hooks in Epic: every health system is its own integration. If a server supports it, US Core lets you ask for its note types with ValueSet/$expand on the DocumentReference type context. Plan for a mapping table per health system anyway.
eCW won't accept a progress-note section without the visit's encounter ID. We now refuse the write before sending it, with a plain message telling the clinician to pick the visit.
Oracle has its own rules. It requires context.period, allows only one encounter, and won't let an update change the encounter. Validate encounter context before the clinician hits Sign. A rejection after signing is the worst moment to show an error.
Back to that false success. eClinicalWorks returned HTTP 200 for a transaction Bundle even when the note inside it had been rejected. That's silent chart-data loss.
Two bugs were stacked. First, the proxy trusted the outer status. Second, the route merged the two legs into one flag: overallSuccess = results.ehr.success || results.cdr.success. If our backup copy saved, the whole write "succeeded," and the desktop app showed success even when the EHR leg had failed.
We fixed both. The desktop app now reports the EHR result on its own. The proxy now opens the response Bundle and treats any failed entry as a failure. A simplified sketch of the check:
function assertEcwBundleSucceeded(bundle: Bundle) {
for (const [i, entry] of (bundle.entry ?? []).entries()) {
const status = entry.response?.status ?? "";
if (!status.startsWith("2")) {
throw new Error(`Bundle entry ${i} rejected: ${status}`);
This is the kind of failure I wrote about in hard truths about LLMs in production. Loud failures get fixed. Silent ones rot.
A few more that bit us:
<, > and ≥. LLMs love all of those. We added a sanitizer.Our background retry job also doesn't trust "pending." When a write goes through, we stamp the note in our repository with an "approved for EHR write-back" tag, and the job only pushes tagged notes. Pending alone doesn't count as approval.
We haven't built addenda for Epic or eCW. Oracle is the only one where we PUT an amendment. Oracle's PUT needs an If-Match header with the current version. It takes a docStatus of final, amended or entered-in-error, and the encounter has to match the original. That's an in-place update.
FHIR's own model is different. An addendum is a new DocumentReference with relatesTo.code = appends pointing at the signed note. That gives you two incompatible ideas of "change a signed note," and you'll need to handle both. Build your internal note model around the FHIR version, a chain of documents, and translate to Oracle's in-place update at the edge. Going the other way loses history.
We don't write FHIR Provenance resources today, and we don't keep every draft version as its own record.
Here's what we do have. Every note create or update on the proxy writes an audit event (ehr_document_create, ehr_document_update). Each event records the vendor and whether the EHR write and our backup copy each succeeded. A compliance-officer objection we kept hearing was "no record of who/what/when/why for auditing." So we built a PHI-minimized, append-only ledger. It records who ran the assistant, what it suggested, and whether the clinician accepted, overrode or dismissed it. MedHook, our integration engine, goes further on the log itself: hash-chained, tamper-evident, six-year retention, archive before delete.
The ledger answers compliance's who/what/when/why question. Provenance is the missing piece. It would put the same answer in a standard format other FHIR systems can read. As I read the R4 spec, the shape is small. The model is a Device with agent type author, the clinician is the attester, and the target points at a specific version of the note. This is a sketch, not something we run, so check it against the Provenance spec before you build on it.
{
"resourceType": "Provenance",
"target": [{ "reference": "DocumentReference/note-1/_history/3" }],
"recorded": "2026-01-01T15:04:00Z",
"agent": [
{
"type": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/provenance-participant-type", "code": "author" }] },
"who": { "reference": "Device/scribe-model" }
},
{
"type": { "coding":
If each Provenance targets one version, you get per-draft history. You can then rebuild the diff between what the model wrote and what the clinician signed. Keep that diff on your side. If you only write final after sign-off, the EHR will only ever hold the signed note.