Integrating high-quality audio and video capabilities into standard web portals is highly demanding. When building telehealth platforms, developers must manage complex WebRTC signaling pipelines, handle varying local networks, and keep real-time communication synced with historical patient records in an EHR.
To bypass custom WebRTC server complexities, our telehealth patient platform leverages a highly elegant architectural combination: Azure Communication Services (ACS) for real-time video/chat session management, and Medplum as the secure, HIPAA-compliant FHIR engine. In this article, we will deep-dive into how the platform joins these systems inside React using custom brand composites and event-driven state machines.
Telehealth State Machine Architecture
Orchestrating video consultations requires moving patients and clinicians through a strict series of UI lifecycle steps, ensuring media streams are turned on only at the appropriate moments:
- Preparation: The client executes device hardware diagnostic tests and synchronizes clinical file attachments.
- Waiting Lobby: The user sits in a lobby, connected to the signaling channel. If scheduled in the future, a real-time countdown banner displays.
- Active Call: The interface transitions immediately once the remote provider joins the call. The portal programmatically activates local streams.
- Post-Call Summary: Captures user-entered consultation notes and fetches appointment documents from Medplum.
Step 1: Bootstrapping the ACS Call Composite
Azure Communication Services provides high-level UI composites via @azure/communication-react. We hook into the platform's state machine, initialize an ACS Token Credential using an authenticated token, and mount the Call Composite wrapper using our brand colors:
import { AzureCommunicationTokenCredential } from '@azure/communication-common';
import {
CallWithChatComposite,
FluentThemeProvider,
createAzureCommunicationCallWithChatAdapter
} from '@azure/communication-react';
const joinCall = useCallback(async (): Promise<void> => {
if (!accessToken || !acsGroupId || !acsChatThreadId || !userId || !endpoint) return;
setLoading(true);
try {
const tokenCredential = new AzureCommunicationTokenCredential(accessToken);
const locator = {
callLocator: { groupId: acsGroupId },
chatThreadId: acsChatThreadId,
};
// Create unified ACS adapter
const callAdapter = await createAzureCommunicationCallWithChatAdapter({
userId: { communicationUserId: userId },
displayName: patientName || 'Patient',
credential: tokenCredential,
endpoint,
locator,
});
// Initially join with camera and microphone muted
callAdapter.joinCall({ cameraOn: false, microphoneOn: false });
setAdapter(callAdapter);
setStep('waiting');
} catch (err) {
console.error('Failed to bootstrap ACS Adapter:', err);
setError('Failed to initialize session.');
} finally {
setLoading(false);
}
}, [accessToken, acsGroupId, acsChatThreadId, userId, endpoint]);
Step 2: Event-Driven Participant Detection
Telehealth platforms must ensure patients do not sit in empty calls wasting local battery and bandwidth. We solve this by joining in a silent state, registering event listeners on the ACS adapter, and programmatically starting the local camera and microphone ONLY when the provider actually joins the room:
useEffect(() => {
if (!adapter || step !== 'waiting') return;
const onParticipantsJoined = (): void => {
// Clinician joined - launch camera, unmute local mic, and start call
adapter.startCamera().catch(console.error);
adapter.unmute().catch(console.error);
setStep('active-call');
// Start call duration timer
setElapsedSeconds(0);
timerRef.current = setInterval(() => {
setElapsedSeconds(prev => prev + 1);
}, 1000);
};
adapter.on('callParticipantsJoined', onParticipantsJoined);
return () => {
adapter.off('callParticipantsJoined', onParticipantsJoined);
};
}, [adapter, step]);
Step 3: Post-Call Medplum Document Retrieval
Once the call ends, we transition immediately to the post-call stage. We query the Medplum-hosted appointment documents via a secure AWS Lambda bot proxy route to fetch clinical notes written by the provider in real-time:
useEffect(() => {
if (step !== 'post-call' || !appointmentId) return;
const fetchClinicalNotes = async (): Promise<void> => {
setLoadingNotes(true);
try {
const response = await fetch(botLambdaUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'get-appointment-documents',
appointmentId,
}),
});
const data = await response.json();
if (data.success) {
const notes = data.documents
.filter((d: any) => d.type === 'clinical-note')
.map((d: any) => ({
id: d.id,
title: d.title,
textContent: d.textContent,
date: d.date,
}));
setClinicalNotes(notes);
}
} catch (err) {
console.error('Failed to sync appointment notes:', err);
} finally {
setLoadingNotes(false);
}
};
fetchClinicalNotes().catch(console.error);
}, [step, appointmentId]);
Telehealth Architecture Best Practices
- Silent Waiting Lobby: Mute all local audio/video hardware on initial connection to minimize system resources and guarantee patient privacy prior to clinician arrival.
- Fluent UI Customization: Use
FluentThemeProviderwrappers with brand palette schemes to blend calling composites into standard web themes. - EHR Synced Summaries: Fetch clinical summaries dynamically using secure appointment tokens on call termination to close the care cycle instantly.
Combining Azure's scalable Communication Service with Medplum's secure, FHIR-compliant EHR framework creates an exceptionally robust, enterprise-grade telehealth infrastructure that delivers seamless patient-clinician consultations at scale.