AI Agent Audit Log in FHIR: AuditEvent and Provenance That Name the Agent
Through a SMART backend client, the EHR logs a service account, not your AI agent. Build per-run AuditEvent and Provenance that name the agent, model and run.
Key takeaways
- Through a SMART backend client, the EHR typically can attribute only the shared client, so your agent runtime must write its own AuditEvent naming the model Device and run ID.
- Every AI write needs a Provenance that links version-specific inputs, the prompt, the model Device and the clinician who approved it.
- Keep the audit log append-only, hash-chained with an external anchor and free of PHI, and flag any resource the agent received that its task didn't declare.
If your AI agent reads the chart through a SMART backend client, your EHR's audit log usually can't tell you which agent read it. It also can't tell you the run, the model version or the reason. It sees a token issued to a client ID, and the shared client is typically all it can attribute. So the answer to "which AI read this patient and why" has to come from an audit layer your agent runtime writes itself, using FHIR AuditEvent and Provenance.
You find the gap the first time someone asks that question. The only honest answer is "our integration did," and that names a client ID, not an agent.
Why EHR audit logs don't show which AI agent accessed a record
A SMART backend client authenticates as a system. The EHR records what it can see, which is a token issued to a client. Three agents, five prompt versions, a nightly batch job and a retry storm all look the same from the EHR's side. I covered why system/ scopes give that client far too much in SMART Backend Services for AI Agents. This post is about the record you keep once the read happens.
The FHIR spec already expects you to write your own record. It says every actor in an auditable event should record an AuditEvent, including applications, processes and services. It calls duplicate entries expected and helpful, because "fewer than expected actors being recorded in a multi-actor process" is itself a security signal. So the EHR keeps its log, and your agent runtime writes a second, richer one next to it.
How to represent an AI agent in a FHIR AuditEvent
FHIR has no natural resource type for an AI agent. In R5, AuditEvent.agent.who must reference Practitioner, PractitionerRole, Organization, CareTeam, Patient, Device or RelatedPerson. There is no Software target. The HL7 AI Transparency IG handles this by modeling the AI system as a plain Device, with manufacturer, version and software identifiers (Moehrke's walkthrough covers it).
Use one Device per model-and-prompt version and one run ID per execution. The Device answers "which AI." The run ID answers "which time." Don't mint a Device per run. You'll flood the directory, and the rollback query you care about works per version anyway.
The run ID travels on every FHIR call the agent makes and lands in every AuditEvent the runtime writes. Then split the agents:
- If a clinician asked for the work, the Practitioner is the agent with
requestor: trueand the Device hasrequestor: false. - If an event triggered the work, such as an encounter closing or a result landing, the Device is the requestor and
basedOnpoints at the Task that represents the trigger. Triggers themselves are covered in Stop Polling the EHR. - The backend client stays in the record as its own agent slice, identified by
client_id, so your log still joins to the EHR's log.
IHE BALP maps OAuth tokens the same way: client_id goes on the client agent, sub on the user agent, and jti in agent.policy. For opaque tokens, BALP says to keep at most 32 characters, taken from the end of the token. That's enough to tie many events to one token without storing a live credential in your audit log.
{
"resourceType": "AuditEvent",
"code": { "coding": [{ "system": "http://hl7.org/fhir/restful-interaction", "code": "read" }] },
"action": "R",
"recorded": "2026-09-14T15:02:11Z",
"patient": { "reference": "Patient/123" },
"basedOn": [{ "reference": "Task/trigger-encounter-closed-8841" }],
"agent": [
{ "who": { "reference": "Device/discharge-summarizer-v4-prompt-17" },
"requestor": true,
"authorization": [{ "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/v3-ActReason", "code": "TREAT" }] }],
"policy": ["urn:token-tail:9c1f...e07a"] },
{ "who": { "identifier": { "system": "urn:ehr:client-id", "value": "backend-svc-01" } },
"requestor": false }
],
"source": { "observer": { "reference": "Device/agent-runtime" } },
"entity": [{
"what": { "reference": "Observation/9f2/_history/3" },
"detail": [{ "type": { "text": "run-id" }, "valueString": "run_01J8Q4..." }]
}]
}
entity.what is version-specific, as the spec recommends. When a lab value gets corrected later, you can still see which version the agent saw.
FHIR AuditEvent R4 vs R5: the fields that bite
Most EHRs you'll integrate with run R4. The sample above is R5. Three of the changed fields are ones this design uses:
- Purpose of use. R4 calls it
agent.purposeOfUse, andpurposeOfEventat the top level. R5 renamed both toauthorization. BALP is built on R4 and usespurposeOfUse. - Requestor. In R4,
agent.requestoris mandatory (1..1) andwhois optional. R5 reverses that. Set both on every agent, every time, in every version. A who/requestor split that assumes requestor is always present only holds in R4. - Patient and basedOn. Both are new in R5. On R4, the patient goes in an entity with type
1(Person) and role1(patient), the BALP way. You'll need an extension or a detail entry to carry the trigger.
Write one internal event shape and two serializers. Don't scatter version checks through your agent code.
FHIR Provenance for AI-generated writes
AuditEvent records access. Provenance records how a resource came to be, and the application that does the create or update prepares it. When the agent writes a draft note, a flag or a CDS card that becomes an order, the write carries a Provenance:
{
"resourceType": "Provenance",
"target": [{ "reference": "DocumentReference/note-552/_history/1" }],
"recorded": "2026-09-14T15:04:40Z",
"agent": [
{ "type": { "coding": [{ "code": "author" }] },
"who": { "reference": "Device/discharge-summarizer-v4-prompt-17" } },
{ "type": { "coding": [{ "code": "verifier" }] },
"who": { "reference": "Practitioner/dr-alvarez" } }
],
"entity": [
{ "role": "source", "what": { "reference": "Observation/9f2/_history/3" } },
{ "role": "source", "what": { "reference": "MedicationRequest/77/_history/2" } },
{ "role": "instantiates", "what": { "reference": "DocumentReference/prompt-template-17" } }
]
}
The input resources are source entities, again version-specific. The prompt is a DocumentReference, which is how the AI Transparency IG models it. The model is the Device. The human who approved the output is a separate agent. If nobody approved it, that agent is missing, and the gap is the signal.
The AI Transparency connectathon's consumer criteria set a good bar: a reviewer must be able to tell that AI was involved, how much, and whether a human reviewed it without reading raw JSON. The same PR carries a practical warning. Consumers have to go looking for the Provenance that points at a resource, because nothing retrieves it automatically, and an allowlist of resource types silently dropped it. Test that your Provenance survives every hop downstream. Writing notes back has its own vendor-specific problems, covered in Writing AI Notes Back to Epic.
Tamper-evident, append-only audit log for AI agent PHI access
The spec says servers that support AuditEvent would not generally accept update or delete operations, and that access would typically be limited to security, privacy or system administration purposes. Build to that.
- Separate store, separate credentials. The agent runtime can append and nothing else. It cannot read the log back or delete from it. Application logs live somewhere else.
- Keep PHI out of it. BALP prefers the minimal pattern "whenever possible": identifiers and references, not content. Your audit log points at
Observation/9f2/_history/3and never copies the value. App logs are where PHI leaks, because someone logs a prompt while debugging. The same leak paths come up in Is Claude Code HIPAA Compliant?. - Chain it. Each record stores the hash of the previous record. Editing or deleting one breaks every hash after it, so tampering shows up in a verification pass. A chain alone won't catch a truncated tail or a fully rewritten chain, so periodically publish or sign the head hash somewhere the runtime can't write.
Don't hash JSON.stringify output directly. Key order isn't guaranteed across serializers, so canonicalize with RFC 8785 (JCS) first. Otherwise your chain will "break" on a library upgrade.
Minimum necessary checks for AI agents
Minimum necessary stays a policy until code enforces it. Every task declares what it needs in config, before it runs. A discharge summarizer might declare Encounter, Condition, MedicationRequest and Observation, with maxPerType set to something like 200. After the run, compare what the agent received against that declaration.
Here BALP and the use case pull against each other. BALP recommends leaving search results out of AuditEvents because they "replicate the database searched multiple times over". You can't check for over-reads without the IDs of what came back, though. Record version-specific entity.what references, never content, and write one AuditEvent per patient as BALP recommends. A 500-patient batch run then produces 500 small records you can segment, not one giant blob.
import { createHash } from "node:crypto";
type Declared = { types: string[]; maxPerType: number };
export function overReads(declared: Declared, returnedRefs: string[]) {
const counts = new Map<string, number>();
for (const ref of returnedRefs) {
const type = ref.split("/")[0];
counts.set(type, (counts.get(type) ?? 0) + 1);
}
return [...counts].filter(
([type, n]) => !declared.types.includes(type) || n > declared.maxPerType,
);
}
// canonicalEvent must already be RFC 8785 (JCS) canonical JSON
export function append(prevHash: string, canonicalEvent: string) {
const hash = createHash("sha256").update(prevHash + canonicalEvent).digest("hex");
return { prevHash, hash, body: canonicalEvent };
}
Any non-empty result from overReads raises an alert. Expect false positives from _include and _revinclude pulling in Practitioner and Organization. Declare those types explicitly instead of loosening the check. A likely cause of a real over-read is a prompt or tool change that quietly widened a search, and this check flags it on the first run after that change ships. Consent-driven restrictions sit on top of this. The engineering side of that is in HIPAA Consent.
The three questions your AI audit trail must answer
If the trail can't answer these on demand, it's decoration:
- Everything an agent touched for this patient. Filter by patient, group by Device and run ID, and list the entity references with timestamps and purpose. The per-patient split makes this a cheap query.
- Every patient a given model version touched. Filter by the Device reference. This is your rollback list. When prompt 17 turns out to drop allergies, you need the list of affected patients and the Provenance-tagged outputs to review, not a guess based on deploy timestamps.
- Which event started this action. Start from the written resource's Provenance, follow it to the run, then follow
basedOnto the trigger Task. If a clinician started it, that person is the requestor agent.
Run all three against real data before anyone asks. If any one of them needs an engineer and a week, the layer isn't done.
Questions people ask
Why does the EHR audit log not show which AI agent accessed a patient record?
A SMART backend client authenticates as a system, so the EHR sees a token issued to a client ID and typically can attribute only that shared client. It can't see which agent, run, model version or purpose sat behind it.
How do you represent an AI agent in a FHIR AuditEvent?
FHIR has no Software target for AuditEvent.agent.who, so model the AI as a Device, one per model-and-prompt version. Carry the per-run ID in entity.detail and keep the backend client as a separate agent slice.
What changed in FHIR AuditEvent between R4 and R5?
R5 renamed agent.purposeOfUse and purposeOfEvent to authorization. It made agent.who mandatory and agent.requestor optional, the reverse of R4, and added patient and basedOn.
Should AuditEvents include FHIR search results?
IHE BALP recommends leaving search results out because of their size and sensitivity. Record version-specific entity.what references instead, which still lets you check for over-reads without copying PHI.