Connecting an AI Agent to a FHIR R4 Sandbox
Walk through connecting a healthcare AI agent to a FHIR sandbox environment, from authentication to reading patient data to writing results back.
TL;DR
- Connecting an AI agent to a FHIR R4 sandbox takes four steps: authenticate with SMART on FHIR, read patient resources, write results back, and handle OperationOutcome errors.
- Over 90% of US hospitals now expose FHIR APIs under ONC 21st Century Cures Act rules (ONC). Every major EHR supports FHIR R4.
- 71% of countries report active FHIR use for national interoperability (Firely 2025 State of FHIR Survey). R4 plus US Core is the production target.
- Sandbox auth is a solved problem. Production auth is where agents fail: tokens expire, scopes narrow, launch context is required.
What you need before you start
This guide walks through connecting a healthcare AI agent to a FHIR R4 sandbox. We cover authentication, reading patient data, writing results back, and handling the errors that trip up most teams.
"FHIR is opening up new avenues of interoperability, such as exchange with patients through apps, and with organizations in other parts of the healthcare value chain such as health insurers, life insurers and life sciences companies."
Micky Tripathi, former National Coordinator for Health IT, ONC
Before diving in, make sure you understand these concepts:
SMART on FHIR. This is the authorization framework that governs how third-party applications access FHIR servers. It builds on OAuth 2.0 and adds healthcare-specific scopes, launch context, and token semantics. If your agent will eventually connect to Epic, Cerner, or any production EHR, SMART on FHIR is not optional.
OAuth 2.0 flows. Two flows matter for agents. Client credentials flow is for backend services that act on their own behalf (no user present). Authorization code flow is for agents that act on behalf of a specific user, like a clinician using your tool during a patient encounter.
FHIR R4 resource types. Your agent will primarily interact with Patient, Encounter, Condition, Observation, MedicationRequest, and DocumentReference resources. For a deeper look at FHIR R4 testing patterns, including search, validation, and US Core compliance, see the FHIR R4 testing guide. Familiarity with the basic structure of these resources will make the rest of this guide much easier to follow.
Step 1: Authentication
Client credentials flow (backend agents)
Most AI agents that process data in the background use client credentials. This flow is straightforward: your agent presents its client ID and client secret (or a signed JWT assertion) to the token endpoint and receives an access token.
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: 'system/Patient.read system/Observation.read system/DocumentReference.write',
}),
});
const { access_token, expires_in } = await tokenResponse.json();The scope parameter defines what your agent can access. SMART on FHIR scopes follow the pattern {context}/{resource}.{permission} where context is patient, user, or system, and permission is read, write, or *.
In a sandbox, scopes are often permissive. In production, expect scope negotiation where the server grants a subset of what you request.
Authorization code flow (user-facing agents)
If your agent operates within a clinician's session (for example, an EHR-embedded app), you need the authorization code flow. This involves redirecting the user to the FHIR server's authorization page, receiving a code, and exchanging it for an access token.
The key difference in healthcare is the launch parameter. When your app is launched from within an EHR, the launch context includes the current patient ID and encounter ID. Your agent uses these to scope its data access to the relevant patient.
// Step 1: Redirect to authorization endpoint
const authUrl = new URL(authEndpoint);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('scope', 'launch patient/Patient.read patient/Observation.read');
authUrl.searchParams.set('state', generateState());
authUrl.searchParams.set('aud', fhirBaseUrl);
// Step 2: After redirect, exchange code for token
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
}),
});
const { access_token, patient, encounter } = await tokenResponse.json();
// patient and encounter IDs are included in the token responseToken management
Access tokens in production environments typically expire in 5 to 15 minutes. Your agent needs to handle token refresh before expiration. Build this into your HTTP client layer rather than handling it per-request.
class FhirClient {
private token: string;
private expiresAt: number;
private async ensureValidToken() {
if (Date.now() >= this.expiresAt - 30_000) {
await this.refreshToken();
}
}
async request(path: string, options?: RequestInit) {
await this.ensureValidToken();
return fetch(`${this.baseUrl}/${path}`, {
...options,
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/fhir+json',
...options?.headers,
},
});
}
}Step 2: Reading patient data
Searching for patients
FHIR search uses query parameters on the resource type endpoint. The most common patient searches:
// Search by name
const response = await fhir.request('Patient?family=Smith&given=John');
// Search by MRN (medical record number)
const response = await fhir.request(
'Patient?identifier=http://hospital.example.org/mrn|MRN12345'
);
// Search by date of birth
const response = await fhir.request('Patient?birthdate=1980-01-15');The response is a FHIR Bundle containing matching resources. Always check Bundle.total and handle pagination.
Using _include for related resources
Rather than making separate requests for each resource type, use _include and _revinclude to pull related data in a single request.
// Get a patient with their conditions and medications in one request
const response = await fhir.request(
'Patient?_id=12345' +
'&_revinclude=Condition:patient' +
'&_revinclude=MedicationRequest:patient' +
'&_revinclude=Observation:patient'
);
const bundle = await response.json();
const patient = bundle.entry.find(e => e.resource.resourceType === 'Patient');
const conditions = bundle.entry
.filter(e => e.resource.resourceType === 'Condition')
.map(e => e.resource);This approach reduces round trips and is significantly faster than fetching resources individually, especially important when your agent has latency constraints.
Handling pagination
FHIR servers paginate large result sets. The Bundle resource includes a link array with next URLs for the following page.
async function getAllResources(initialUrl: string): Promise<Resource[]> {
const resources: Resource[] = [];
let url: string | null = initialUrl;
while (url) {
const response = await fhir.request(url);
const bundle = await response.json();
for (const entry of bundle.entry || []) {
resources.push(entry.resource);
}
const nextLink = bundle.link?.find(l => l.relation === 'next');
url = nextLink?.url || null;
}
return resources;
}Be aware that some servers use opaque pagination tokens that expire. If your agent processes data slowly, tokens may become invalid between pages.
Step 3: Writing results back
Most AI agents need to write results back to the FHIR server. Common patterns include creating DocumentReferences for generated reports, Observations for computed values, and Task resources for workflow items.
Creating a DocumentReference
const documentReference = {
resourceType: 'DocumentReference',
status: 'current',
type: {
coding: [{
system: 'http://loinc.org',
code: '11506-3',
display: 'Progress note',
}],
},
subject: { reference: `Patient/${patientId}` },
date: new Date().toISOString(),
content: [{
attachment: {
contentType: 'text/plain',
data: Buffer.from(reportText).toString('base64'),
},
}],
};
const response = await fhir.request('DocumentReference', {
method: 'POST',
headers: { 'Content-Type': 'application/fhir+json' },
body: JSON.stringify(documentReference),
});Creating an Observation
const observation = {
resourceType: 'Observation',
status: 'final',
code: {
coding: [{
system: 'http://loinc.org',
code: '85354-9',
display: 'Blood pressure panel',
}],
},
subject: { reference: `Patient/${patientId}` },
effectiveDateTime: new Date().toISOString(),
component: [
{
code: { coding: [{ system: 'http://loinc.org', code: '8480-6', display: 'Systolic' }] },
valueQuantity: { value: 120, unit: 'mmHg', system: 'http://unitsofmeasure.org', code: 'mm[Hg]' },
},
{
code: { coding: [{ system: 'http://loinc.org', code: '8462-4', display: 'Diastolic' }] },
valueQuantity: { value: 80, unit: 'mmHg', system: 'http://unitsofmeasure.org', code: 'mm[Hg]' },
},
],
};
const response = await fhir.request('Observation', {
method: 'POST',
headers: { 'Content-Type': 'application/fhir+json' },
body: JSON.stringify(observation),
});After a successful write, the server returns the created resource with a server-assigned id. Always store this ID for future reference or updates.
Step 4: Handling errors
FHIR servers communicate errors through OperationOutcome resources. Understanding these is key to building a solid agent.
OperationOutcome responses
{
"resourceType": "OperationOutcome",
"issue": [{
"severity": "error",
"code": "not-found",
"diagnostics": "Patient/99999 not found"
}]
}Your agent should parse OperationOutcome issues and take appropriate action based on the severity and code.
Rate limiting (429)
Production FHIR servers enforce rate limits. When you receive a 429 response, respect the Retry-After header.
async function requestWithRetry(path: string, options?: RequestInit, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fhir.request(path, options);
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '5');
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
continue;
}
return response;
}
throw new Error(`Failed after ${maxRetries} retries`);
}Common error patterns
- 400 Bad Request. Your resource failed validation. Check required fields, coding system URIs, and reference formats.
- 401 Unauthorized. Token expired or invalid. Refresh and retry.
- 403 Forbidden. Your scopes do not cover this operation. Check your authorized scopes.
- 404 Not Found. Resource does not exist. Handle gracefully rather than crashing.
- 409 Conflict. Version conflict on update. Re-read the resource, merge changes, and retry with the current version.
- 422 Unprocessable Entity. Structurally valid but semantically wrong. Often a business rule violation.
A working example: patient summary agent
Here is a complete flow that ties everything together. This agent reads a patient's data and generates a clinical summary.
async function generatePatientSummary(patientId: string) {
// 1. Fetch patient with related clinical data
const bundle = await fhir.request(
`Patient?_id=${patientId}` +
`&_revinclude=Condition:patient` +
`&_revinclude=MedicationRequest:patient` +
`&_revinclude=AllergyIntolerance:patient`
).then(r => r.json());
// 2. Extract and organize resources
const patient = bundle.entry.find(e => e.resource.resourceType === 'Patient')?.resource;
const conditions = bundle.entry
.filter(e => e.resource.resourceType === 'Condition')
.map(e => e.resource);
const medications = bundle.entry
.filter(e => e.resource.resourceType === 'MedicationRequest')
.map(e => e.resource);
// 3. Generate summary using your AI model
const summary = await generateSummaryWithLLM(patient, conditions, medications);
// 4. Write summary back as a DocumentReference
await fhir.request('DocumentReference', {
method: 'POST',
headers: { 'Content-Type': 'application/fhir+json' },
body: JSON.stringify({
resourceType: 'DocumentReference',
status: 'current',
subject: { reference: `Patient/${patientId}` },
type: { coding: [{ system: 'http://loinc.org', code: '34133-9', display: 'Summary of episode note' }] },
content: [{ attachment: { contentType: 'text/plain', data: Buffer.from(summary).toString('base64') } }],
}),
});
}What sandbox testing does not cover
Even after building a solid sandbox integration, be aware of the gaps.
Data volume. Sandboxes typically have dozens of patients. Production systems have millions. Queries that work fine in a sandbox may time out in production.
Data quality. Sandbox data is clean and consistent. Production data has missing fields, inconsistent coding, and resources that stretch the FHIR specification in creative ways. This is the core of the FHIR sandbox problem: test environments give you false confidence about your agent's resilience.
Network conditions. Sandboxes run on fast, reliable networks. Production FHIR servers sit behind VPNs, firewalls, and load balancers that add latency and occasionally drop connections.
Concurrent access. Your agent might be the only client hitting a sandbox. In production, the FHIR server handles hundreds of concurrent clients, which affects response times and rate limits.
The sandbox is where you build confidence in your integration logic. But you need a more realistic test environment to build confidence that your agent will survive production. FHIR is also just one interface your agent will need. Production agents typically span multiple interfaces including HL7v2 feeds, voice IVR systems, and payer portals.
Key Takeaways
- Use SMART on FHIR, not raw OAuth. Client credentials work for backend agents. Authorization code flow is required for clinician-facing apps.
- Production tokens expire in 5 to 15 minutes. Build refresh into the HTTP client, not per-request.
- Use
_includeand_revincludeto bundle related resources in one request. Latency matters for agents. - Always write back with
Content-Type: application/fhir+jsonand store the server-assignedidfor future updates. - Parse OperationOutcome
issue.code, not just HTTP status. Retry logic depends on the structured code. - The sandbox is the start of testing, not the end. Production data shapes differ from clean sandbox data.
FAQ
What is the difference between client credentials and authorization code flow?
Client credentials suit backend agents that act on their own behalf with no user present. Authorization code flow suits agents launched inside a clinician session, where the EHR passes patient and encounter context via the launch parameter.
Which FHIR sandbox should I start with?
Start with a SMART on FHIR compliant sandbox that supports the same scopes you will use in production. Epic's Open FHIR Sandbox covers Epic-shaped data. HAPI FHIR covers spec-compliant behavior but skips auth entirely. Neither reflects production data quality.
How do I handle token expiration during long workflows?
Wrap every request in a client layer that checks token expiration and refreshes 30 seconds before expiry. If a multi-step workflow runs longer than the token lifetime, the client refreshes transparently between steps.
What scopes should I request?
Request the narrowest scope that covers your workflow. system/Patient.read and system/Observation.read for read-only agents. Add .write scopes only for resources you will actually create. Broad * scopes are rejected in production.
Related articles
insightsHIMSS26's Agentic AI Gap Is an Eval Problem
HIMSS26 showed health systems deploying agents faster than they can audit them. The fix isn't more governance theater, it's independent simulation.
insightsThe Agent RFP: How Hospitals Should Evaluate AI in 2026
Slide decks and 3-month pilots can't tell you if an AI agent survives your workflows. Here's how the agent RFP replaces slideware with sim-based bakeoffs.