IMPORTANT UPDATE: The package has been renamed from
@nora/recorder
to@nora-technology/recorder
. This documentation has been updated to reflect this change. If you were previously using@nora/recorder
, please update your dependencies to use the new package name.
- Overview
- Integration Requirements
- Installation Methods
- Basic Integration
- Display Modes
- Task Types
- Context Recommendations System
- Customization Options
- Multi-Tab Environment Handling
- Lifecycle Management
- Event Handling
- Framework-Specific Integration
- API Reference
- Recording Workflow
- Security Considerations
- Troubleshooting
- Advanced Usage
- Best Practices
- Example Implementations
NoraRecorder is an NPM package (@nora-technology/recorder
) that provides a voice recording component for medical applications, specifically designed for integration with Electronic Health Record (EHR) platforms. The component allows doctors to record consultations, which are then processed to generate structured clinical notes that can be inserted back into the EHR.
Key capabilities:
- Audio recording with pause, resume, and stop functionality
- Support for multiple microphones
- Integration with note generation services
- Support for different languages and templates
- Built-in processing status tracking
- Compatible with multi-tab EHR environments
Before integrating NoraRecorder, ensure you have the following:
- API Key: Provided by Nora for your specific EHR platform
- API Base URL: The endpoint for Nora's backend services
- Doctor ID: A unique identifier for each doctor using the system
- Consultation ID: A unique identifier for each consultation/patient encounter
These parameters are required for proper operation and to ensure notes are correctly associated with doctors and consultations.
For projects using a modern JavaScript build system:
npm install @nora-technology/recorder
# or
yarn add @nora-technology/recorder
For direct integration into HTML:
<!-- Latest version (not recommended for production) -->
<script src="https://unpkg.com/@nora-technology/recorder@latest/dist/nora-recorder-easy.js"></script>
<!-- Specific version (recommended for production) -->
<script src="https://unpkg.com/@nora-technology/recorder@1.0.29/dist/nora-recorder-easy.js"></script>
Every NoraRecorder initialization requires these parameters:
Parameter | Description | Type | Example |
---|---|---|---|
apiKey |
Authentication key provided by Nora | string | "your-api-key-123" |
doctorID |
Unique identifier for the doctor | string | "doctor-uuid-456" |
consultationID |
Unique identifier for the consultation | string | "consult-uuid-789" |
apiBaseUrl |
Base URL for Nora's API services | string | "https://api.nora.ai/v1" |
taskType |
Type of task being recorded (optional) | string |
"clinical_notes" (default), "referral_letter" , "sick_note" , "patient_notes" , "vitals"
|
NoraRecorder uses a promise-based factory function pattern for reliable initialization and error handling:
// Wait for NoraRecorder to be ready
NoraRecorder.ready(function() {
// Initialize with factory function (NOT constructor)
NoraRecorder({
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: 'CONSULTATION_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT',
taskType: 'clinical_notes' // Optional: defaults to 'clinical_notes'
}).then(recorder => {
console.log('Recorder initialized successfully');
// Store reference if needed for later interaction
window.currentRecorder = recorder;
}).catch(error => {
console.error('Failed to initialize recorder:', error);
});
});
// Handle loading errors
NoraRecorder.onError(function(error) {
console.error('Failed to load NoraRecorder:', error);
});
// Clean up when done
// NoraRecorder.cleanup();
Important: NoraRecorder uses a factory function pattern
NoraRecorder(options)
that returns a Promise. This provides proper script loading, initialization, and error handling.
This pattern handles script loading, initialization, and readiness detection automatically. It uses a promise-based API for better error handling and asynchronous flow.
The NoraRecorder integration pattern consists of several key components working together:
At the very beginning of execution, the loader sets a global flag:
window.__NORA_RECORDER_BUNDLED = true;
This flag indicates that the bundled version is being used, preventing attempts to load external scripts that would conflict.
The loader follows this sequence:
- Sets the bundled flag immediately (outside any function scope)
- Initializes the loader module with state management
- Initializes the main NoraRecorder module
- Creates a factory function wrapper around the original constructor
- Sets up global objects and ready state
The loader manages multiple states:
-
LOADING
: Initial state while script is loading -
READY
: NoraRecorder is ready to use -
ERROR
: An error occurred during loading
These states determine how callbacks are handled and when initialization is possible.
The loader maintains queues of callbacks for both ready and error states:
NoraRecorder.ready(callback); // Called when ready
NoraRecorder.onError(callback); // Called if loading fails
When the state changes, all queued callbacks are executed once.
The factory function returns a promise that resolves with the recorder instance:
NoraRecorder(options).then(recorder => {
// Use recorder
}).catch(error => {
// Handle error
});
When the loader is used in standalone mode (not bundled), it:
- Checks if NoraRecorder is already available
- If not, loads the script from a CDN
- Monitors load status and sets ready state when available
NoraRecorder supports two display modes that determine how the component appears in your EHR interface.
In floating mode, the recorder appears as a draggable button that floats above the content:
NoraRecorder({
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: 'CONSULTATION_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT',
// Optional position configuration
position: { x: 20, y: 20 }
}).then(recorder => {
console.log('Floating recorder initialized');
});
Characteristics:
- Positioned absolutely in the viewport
- Draggable by users to any position
- Always visible (high z-index)
- Can be initially positioned with the
position
parameter
In embedded mode, the recorder is embedded within a container in your EHR interface:
// Create a container for the recorder
const container = document.getElementById('recorder-container');
NoraRecorder({
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: 'CONSULTATION_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT',
// Container for embedding
container: container,
// Must set position to null for embedded mode
position: null,
// Recommended for embedded mode
snackbarPosition: "below-component"
}).then(recorder => {
console.log('Embedded recorder initialized');
});
HTML Setup for Embedded Mode:
<!-- Container for embedded recorder - minimum dimensions are important -->
<div id="recorder-container" style="min-height: 48px; min-width: 290px; display: inline-block;"></div>
Characteristics:
- Positioned within the specified container
- Not draggable
- Visibility depends on container visibility
- Follows container scroll position
- Requires minimum dimensions (290px width × 48px height)
NoraRecorder supports different task types that determine which endpoint is used for processing recordings and how the user interface behaves. Each task type corresponds to a specific type of medical documentation and provides a tailored experience for different clinical workflows.
Task Type | Internal Name | Description | Endpoint | Terminal Status |
---|---|---|---|---|
Clinical Notes | clinical_notes |
Standard consultation notes (default) | /nora/clinical_notes |
sent |
Referral Letter | referral_letter |
Letters referring patients to specialists | /nora/referral_letter |
generated |
Sick Note | sick_note |
Medical certificates for sick leave | /nora/sick_note |
generated |
Patient Notes | patient_notes |
General patient documentation | /nora/patient_notes |
generated |
Vitals | vitals |
Recording of vital signs and measurements | /nora/vitals |
generated |
The task type system provides different user experiences based on the type of documentation being created:
- UI Elements: Shows template selection, language selection, microphone selection, and mode toggle (consultation/dictation)
-
Headers Sent: Includes
template_used
andCon-Mode
headers in API requests -
Terminal Status: Reaches completion when status is
sent
- Use Case: Standard consultation documentation with full template and mode options
All other task types (referral_letter
, sick_note
, patient_notes
, vitals
) share the same simplified interface:
- UI Elements: Shows only language selection and microphone selection
- Task Type Display: Shows "Recording for [Task Type]" text instead of template/mode controls
-
Headers Sent: Does NOT send
template_used
orCon-Mode
headers (to avoid API rejection) -
Terminal Status: Reaches completion when status is
generated
- Use Case: Specialized documentation types that don't require template or mode selection
- Generate Button: For non-clinical task types, if clinical notes already exist for the consultation, a Generate button appears in the recorder interface. This allows generating the specific task type content without recording, using existing clinical notes as context.
For non-clinical task types (referral_letter
, sick_note
, patient_notes
, vitals
), when clinical notes already exist for the consultation, a Generate button appears in the recorder interface. This button allows you to generate content for the specific task type using the existing clinical notes as context, without needing to record new audio.
Generate Button Features:
- Context-Aware: Uses existing clinical notes from the same consultation as context
- No Recording Required: Generates content directly without audio recording
-
Same Event System: Emits the same status events as recording (
generation-started
,generation-stopped
,generation-failed
) - Task-Type Specific: Generates content appropriate for the selected task type
-
Pre-Prompt Integration: Optionally uses EHR content via
prePromptCallback
for enhanced context
Status Events Emitted:
// When Generate button is clicked
document.addEventListener('nora-recorder-status', (event) => {
const { type, consultationID, taskType } = event.detail;
switch (type) {
case 'generation-started':
console.log(`Generate started for ${taskType}`);
break;
case 'generation-stopped':
console.log(`Generate processing for ${taskType}`);
break;
case 'generation-failed':
console.error(`Generate failed for ${taskType}`);
break;
}
});
Content Events:
The Generate button produces the same content-ready
event as recording when processing completes:
document.addEventListener('nora-recorder-content', (event) => {
const { type, content, taskType } = event.detail;
if (type === 'content-ready') {
// Generated content is ready, route to appropriate field
console.log(`Generated ${taskType} content: ${content.length} characters`);
}
});
// Clinical Notes (default) - Full interface with template and mode selection
NoraRecorder({
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: 'CONSULTATION_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT'
// taskType defaults to 'clinical_notes'
}).then(recorder => {
console.log('Recording for Clinical Notes');
// UI will show: template selection, mode toggle, language, microphone
});
// Referral Letter - Simplified interface
NoraRecorder({
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: 'CONSULTATION_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT',
taskType: 'referral_letter'
}).then(recorder => {
console.log('Recording for Referral Letter');
// UI will show: "Recording for Referral Letter" text, language, microphone
});
// Sick Note - Simplified interface
NoraRecorder({
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: 'CONSULTATION_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT',
taskType: 'sick_note'
}).then(recorder => {
console.log('Recording for Sick Note');
// UI will show: "Recording for Sick Note" text, language, microphone
});
// Patient Notes - Simplified interface
NoraRecorder({
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: 'CONSULTATION_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT',
taskType: 'patient_notes'
}).then(recorder => {
console.log('Recording for Patient Notes');
// UI will show: "Recording for Patient Notes" text, language, microphone
});
// Vitals - Simplified interface
NoraRecorder({
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: 'CONSULTATION_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT',
taskType: 'vitals'
}).then(recorder => {
console.log('Recording for Vitals');
// UI will show: "Recording for Vitals" text, language, microphone
});
When recording starts, the user will see a notification indicating the task type:
- "Recording for Clinical Notes"
- "Recording for Referral Letter"
- "Recording for Sick Note"
- "Recording for Patient Notes"
- "Recording for Vitals"
This helps users understand what type of documentation they are creating.
In real EHR systems, you typically want to use the same consultation ID for all task types within a single patient visit, but create separate recorders for each type of documentation:
// Example: Patient consultation with multiple documentation types
const CONSULTATION_ID = "PATIENT-VISIT-12345"; // Same for all task types
const DOCTOR_ID = "DR-SMITH-001";
// Clinical notes recorder
NoraRecorder({
apiKey: 'YOUR_API_KEY',
doctorID: DOCTOR_ID,
consultationID: CONSULTATION_ID, // Same consultation ID
apiBaseUrl: 'YOUR_API_ENDPOINT',
taskType: 'clinical_notes',
container: document.getElementById('clinical-notes-recorder')
}).then(recorder => {
// Store reference for clinical notes
window.clinicalNotesRecorder = recorder;
});
// Referral letter recorder (separate instance, same consultation)
NoraRecorder({
apiKey: 'YOUR_API_KEY',
doctorID: DOCTOR_ID,
consultationID: CONSULTATION_ID, // Same consultation ID
apiBaseUrl: 'YOUR_API_ENDPOINT',
taskType: 'referral_letter',
container: document.getElementById('referral-letter-recorder')
}).then(recorder => {
// Store reference for referral letter
window.referralLetterRecorder = recorder;
});
// Sick note recorder (separate instance, same consultation)
NoraRecorder({
apiKey: 'YOUR_API_KEY',
doctorID: DOCTOR_ID,
consultationID: CONSULTATION_ID, // Same consultation ID
apiBaseUrl: 'YOUR_API_ENDPOINT',
taskType: 'sick_note',
container: document.getElementById('sick-note-recorder')
}).then(recorder => {
// Store reference for sick note
window.sickNoteRecorder = recorder;
});
For applications that need to switch between task types dynamically, create a new recorder instance for each task type:
let currentRecorder = null;
const CONSULTATION_ID = "PATIENT-VISIT-12345";
function switchToTaskType(taskType) {
// Clean up existing recorder
if (currentRecorder) {
currentRecorder.cleanup();
currentRecorder = null;
}
// Clear container
const container = document.getElementById('recorder-container');
container.innerHTML = '';
// Initialize new recorder for the selected task type
NoraRecorder({
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: CONSULTATION_ID, // Same consultation ID
apiBaseUrl: 'YOUR_API_ENDPOINT',
taskType: taskType,
container: container,
position: null,
snackbarPosition: "below-component"
}).then(recorder => {
currentRecorder = recorder;
console.log(`Switched to ${taskType} recorder`);
});
}
// Usage
switchToTaskType('clinical_notes'); // Shows full interface
switchToTaskType('referral_letter'); // Shows simplified interface
switchToTaskType('sick_note'); // Shows simplified interface
Each task type generates content that should be integrated into the appropriate section of your EHR:
// Listen for content events and route to appropriate EHR sections
document.addEventListener('nora-recorder-content', (event) => {
const { type, consultationID, content, taskType } = event.detail;
if (type === 'content-ready') {
// Route content to appropriate EHR field based on task type
switch (taskType) {
case 'clinical_notes':
// Insert into main clinical notes field
document.getElementById('clinical-notes-textarea').value = content;
break;
case 'referral_letter':
// Insert into referral letter field
document.getElementById('referral-letter-textarea').value = content;
break;
case 'sick_note':
// Insert into sick note field
document.getElementById('sick-note-textarea').value = content;
break;
case 'patient_notes':
// Insert into patient notes field
document.getElementById('patient-notes-textarea').value = content;
break;
case 'vitals':
// Insert into vitals field or parse structured vitals data
document.getElementById('vitals-textarea').value = content;
break;
}
// Show success notification
console.log(`${taskType} content generated and inserted`);
}
});
All task types show a completion interface (green checkmark + delete button) when they reach their terminal status:
-
Clinical Notes: Shows completion when
processingStatus === 'sent'
-
Other Task Types: Show completion when
processingStatus === 'generated'
The delete button is task-type aware and will:
- Show appropriate tooltip (e.g., "Delete referral letter recording and restart")
- Route delete requests to the correct endpoint for the task type
- Reset the recorder to allow new recordings of the same task type
// API request includes template and mode headers
Headers: {
'DoctorID': 'DOCTOR_ID',
'ConsultationID': 'CONSULTATION_ID',
'template_used': 'selected_template_id',
'Con-Mode': 'consultation', // or 'dictation'
'lang': 'en',
'Audio-Duration': '45',
'Microphone-Selected': 'Default Microphone'
}
// API request excludes template and mode headers
Headers: {
'DoctorID': 'DOCTOR_ID',
'ConsultationID': 'CONSULTATION_ID',
// NO template_used header
// NO Con-Mode header
'lang': 'en',
'Audio-Duration': '45',
'Microphone-Selected': 'Default Microphone'
}
This ensures that non-clinical notes task types don't send headers that would cause API rejection.
The Context Recommendations System is an intelligent feature that analyzes completed clinical notes and provides contextual recommendations for additional documentation that may be needed for the consultation. This system helps ensure comprehensive patient care by suggesting relevant follow-up actions.
The context system works by:
- Analyzing completed clinical notes using AI to understand the consultation content
- Identifying potential needs such as referrals, sick notes, blood tests, etc.
- Displaying recommendations in a user-friendly dropdown component
- Integrating seamlessly with the existing NoraRecorder workflow
Key Features:
- Automatic context analysis after clinical notes completion
- Visual recommendations dropdown with clear icons and labels
- Configurable positioning (below, right, or left of recorder)
- Optional system that can be completely disabled
- Non-intrusive design that doesn't interfere with normal workflow
The context system operates in two main scenarios:
When using the clinical_notes
task type:
- User records and completes clinical notes
- System automatically calls the context API with the consultation details
- Context recommendations appear below the recorder
- User can expand/collapse the recommendations dropdown
When using other task types (referral_letter
, sick_note
, etc.):
- System checks if clinical notes already exist for this consultation
- If clinical notes exist, context recommendations are shown immediately
- If no clinical notes exist, no context recommendations are displayed
The context system can be configured during NoraRecorder initialization:
NoraRecorder({
// Required parameters
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: 'CONSULTATION_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT',
// Context system configuration
enableContextChecker: true, // Enable/disable context system (default: true)
contextPosition: "below" // Position: "below", "right", "left" (default: "below")
}).then(recorder => {
console.log('Recorder with context recommendations initialized');
});
Parameter | Type | Description | Default | Options |
---|---|---|---|---|
enableContextChecker |
boolean | Enable/disable entire context system | true |
true , false
|
contextPosition |
string | Position of context dropdown relative to recorder | "below" |
"below" , "right" , "left"
|
Below (Default)
contextPosition: "below" // Appears directly below the recorder
Right Side
contextPosition: "right" // Appears to the right of the recorder
Left Side
contextPosition: "left" // Appears to the left of the recorder
The context system integrates with your backend API to analyze clinical notes and provide recommendations.
-
URL:
{apiBaseUrl}/nora/context_checker
- Method: GET
-
Headers:
-
x-api-key
: Your API key -
DoctorID
: Doctor identifier -
ConsultationID
: Consultation identifier
-
{
"context_checker_output": {
"referral_needed": true,
"sick_note_needed": false,
"patient_notes_needed": false,
"blood_test_needed": true,
"follow_up_consultation_needed": false
}
}
API Field | Display Label | Icon | Description |
---|---|---|---|
referral_needed |
Referral Letter | 📋 | Patient may need referral to specialist |
sick_note_needed |
Sick Note | 🏥 | Patient may need medical certificate |
patient_notes_needed |
Patient Notes | 📝 | Additional patient documentation needed |
blood_test_needed |
Blood Test | 🩸 | Blood work may be required |
follow_up_consultation_needed |
Follow-up Consultation | 📅 | Follow-up appointment recommended |
The context system provides a clean, collapsible dropdown interface:
- Width: 290px (matches recorder width)
- Header: "Nora recommendations" with expand/collapse icon
- Content: List of recommended actions with icons and status indicators
- Styling: Consistent with recorder design language
- Animation: Smooth expand/collapse transitions
- Click header to expand/collapse recommendations
- Hover effects on recommendation items
- Visual indicators show "Recommended" status for each item
- Empty state shows "No additional recommendations" when no actions needed
Collapsed State
┌─────────────────────────────────────┐
│ Nora recommendations ▼ │
└─────────────────────────────────────┘
Expanded State with Recommendations
┌─────────────────────────────────────┐
│ Nora recommendations ▲ │
├─────────────────────────────────────┤
│ 📋 Referral Letter [Recommended] │
│ 🩸 Blood Test [Recommended] │
└─────────────────────────────────────┘
Expanded State with No Recommendations
┌─────────────────────────────────────┐
│ Nora recommendations ▲ │
├─────────────────────────────────────┤
│ ✅ No additional recommendations │
└─────────────────────────────────────┘
The context system doesn't emit separate events but integrates with the existing NoraRecorder event system. Context recommendations appear automatically based on the clinical workflow.
Clinical Notes Completion
// Context check is triggered automatically when clinical notes reach terminal status
// No additional code needed - recommendations appear automatically
Non-Clinical Notes Initialization
// Context check happens automatically during initialization if clinical notes exist
// No additional code needed - recommendations appear if applicable
You can check if context recommendations are visible:
// Check if context component is currently displayed
const contextVisible = document.querySelector('.context-checker-container') !== null;
if (contextVisible) {
console.log('Context recommendations are currently displayed');
}
The context system can be completely disabled if not needed for your EHR integration:
NoraRecorder({
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: 'CONSULTATION_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT',
enableContextChecker: false // Completely disable context system
}).then(recorder => {
console.log('Recorder without context recommendations initialized');
});
- Reduced API calls: No requests to context endpoint
- Simplified UI: No context dropdown component
- Lower bandwidth: Less network traffic
- Faster initialization: Fewer background processes
- EHR systems that don't need context recommendations
- Testing environments where context isn't relevant
- Minimal implementations focused only on recording
- Systems with their own recommendation engines
Context Recommendations Not Appearing
-
Check if context system is enabled
console.log('Context enabled:', recorder.enableContextChecker);
-
Verify API endpoint configuration
- Ensure
/nora/context_checker
endpoint exists - Check API key has permissions for context endpoint
- Verify CORS settings allow context API calls
- Ensure
-
Check clinical notes status
- Context only appears after clinical notes completion
- For non-clinical task types, clinical notes must exist first
Context API Errors
- Check network tab for failed API calls to context endpoint
-
Verify headers include
x-api-key
,DoctorID
,ConsultationID
- Check API response format matches expected structure
Context Component Positioning Issues
- Ensure container has relative positioning (for embedded mode)
- Check for CSS conflicts that might affect positioning
- Verify minimum container dimensions (290px width minimum)
Enable debug logging to troubleshoot context issues:
// Check console for context-related log messages
// Look for messages starting with "Context check" or "Context checker"
Use the provided test sites to verify context functionality:
-
single-consultation-demo.html
- Context system enabled -
no-context.html
- Context system disabled
Compare behavior between these sites to ensure context system is working correctly.
NoraRecorder allows customization of its appearance:
NoraRecorder({
// Required parameters
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: 'CONSULTATION_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT',
// Visual customization
size: 70, // Button size in pixels
primaryColor: "#00796b", // Primary color
secondaryColor: "#e0f2f1" // Secondary color
}).then(recorder => {
console.log('Customized recorder initialized');
});
For floating mode, you can control the initial position:
NoraRecorder({
// Required parameters
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: 'CONSULTATION_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT',
// Positioning (in pixels from top-left)
position: { x: 50, y: 100 }
}).then(recorder => {
console.log('Positioned recorder initialized');
});
You can control how notifications appear:
NoraRecorder({
// Required parameters
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: 'CONSULTATION_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT',
// Notification positioning
snackbarPosition: "below-component" // or "bottom-center"
}).then(recorder => {
console.log('Recorder with custom notifications initialized');
});
The NoraRecorder supports an optional pre-prompt feature that allows EHRs to provide additional context from existing notes or patient data. This context is sent along with the audio recording to improve the AI's understanding and generate more accurate notes.
Key Features:
- Completely optional - existing integrations continue to work unchanged
- Dynamic content retrieval - gets fresh content at upload time
- Flexible callback system - EHRs can implement any logic to gather context
- Graceful error handling - continues without pre-prompt if callback fails
- Works with all task types
NoraRecorder({
// Required parameters
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: 'CONSULTATION_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT',
// Optional: Provide current notes as context
prePromptCallback: function() {
// Return current content from EHR notes field
const notesTextarea = document.getElementById('clinical-notes-textarea');
return notesTextarea ? notesTextarea.value.trim() : '';
}
}).then(recorder => {
console.log('Recorder with pre-prompt capability initialized');
});
The callback can return any relevant context from the EHR:
NoraRecorder({
// Required parameters
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: 'CONSULTATION_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT',
// Advanced pre-prompt callback
prePromptCallback: function() {
const sections = [];
// Patient demographics
const patientName = document.getElementById('patient-name')?.textContent;
if (patientName) sections.push(`Patient: ${patientName}`);
// Existing notes
const existingNotes = document.getElementById('notes-textarea')?.value;
if (existingNotes?.trim()) sections.push(`Existing Notes:\n${existingNotes}`);
// Vital signs
const vitals = document.getElementById('vitals-section')?.textContent;
if (vitals?.trim()) sections.push(`Vitals: ${vitals}`);
// Current medications
const medications = Array.from(document.querySelectorAll('.medication-item'))
.map(item => item.textContent.trim())
.filter(text => text);
if (medications.length) sections.push(`Current Medications:\n${medications.join('\n')}`);
// Chief complaint
const chiefComplaint = document.getElementById('chief-complaint')?.value;
if (chiefComplaint?.trim()) sections.push(`Chief Complaint: ${chiefComplaint}`);
return sections.join('\n\n');
}
}).then(recorder => {
console.log('Recorder with comprehensive pre-prompt initialized');
});
- EHR initializes NoraRecorder with optional
prePromptCallback
- User records audio normally
- User clicks stop/upload button
-
IF
prePromptCallback
is provided:- Recorder calls the callback to get current EHR content
- Content is included in the presigned URL request as
task_type_pre_prompt
- Backend processes both audio and pre-prompt for enhanced context
- AI generates more accurate notes using both audio and existing context
The pre-prompt system includes robust error handling:
NoraRecorder({
// Required parameters
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: 'CONSULTATION_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT',
prePromptCallback: function() {
try {
// Your EHR-specific logic here
return getNotesFromComplexEHRSystem();
} catch (error) {
// If callback fails, recorder continues without pre-prompt
console.warn('Pre-prompt callback failed:', error);
return ''; // Return empty string to continue without context
}
}
});
Error Handling Behavior:
- If
prePromptCallback
throws an error, it logs a warning and continues without pre-prompt - If callback returns non-string value, it's treated as empty
- If callback returns empty/null, no pre-prompt is sent
- Backend handles missing pre-prompt gracefully
Simple Text Area EHR:
prePromptCallback: function() {
return document.getElementById('notes-textarea').value || '';
}
Multi-Section EHR:
prePromptCallback: function() {
const sections = {
'Chief Complaint': document.getElementById('chief-complaint')?.value,
'History': document.getElementById('history')?.value,
'Assessment': document.getElementById('assessment')?.value
};
return Object.entries(sections)
.filter(([key, value]) => value?.trim())
.map(([key, value]) => `${key}: ${value}`)
.join('\n\n');
}
Structured Data EHR:
prePromptCallback: function() {
// Get structured data from your EHR's data model
const patientData = getCurrentPatientData();
const visitData = getCurrentVisitData();
const context = [];
if (patientData.allergies?.length) {
context.push(`Allergies: ${patientData.allergies.join(', ')}`);
}
if (visitData.chiefComplaint) {
context.push(`Chief Complaint: ${visitData.chiefComplaint}`);
}
if (visitData.vitals) {
context.push(`Vitals: BP ${visitData.vitals.bp}, HR ${visitData.vitals.hr}`);
}
return context.join('\n');
}
NoraRecorder is designed to prevent multiple simultaneous recordings across different consultations or tabs. This is an intentional behavior to ensure that users don't accidentally start multiple recordings.
Important: Only one recording can be active at any time across all NoraRecorder instances.
There are two valid patterns for multi-tab environments, depending on your use case:
Use this pattern when users won't switch tabs during active recordings:
IMPORTANT: This pattern reinitializes the recorder for each tab switch and does not preserve recordings across tab switches.
// ✅ CORRECT: Single instance with reinitialization
let currentRecorder = null;
function switchToConsultation(consultationID) {
// Clean up existing recorder
if (currentRecorder) {
currentRecorder.cleanup();
currentRecorder = null;
}
// Initialize new recorder
NoraRecorder({
consultationID: consultationID,
// ... other config
}).then(recorder => {
currentRecorder = recorder;
});
}
Use this pattern when users need to switch tabs while a recording continues in the background:
// ✅ CORRECT: Multiple instances with proper management
const consultationRecorders = new Map();
async function initializeRecorderForTab(consultationID, container) {
const recorder = await NoraRecorder({
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: consultationID,
apiBaseUrl: 'YOUR_API_ENDPOINT',
container: container,
position: null,
snackbarPosition: "below-component"
});
consultationRecorders.set(consultationID, recorder);
return recorder;
}
function switchToTab(consultationID) {
// Show the tab (your existing tab switching logic)
showTab(consultationID);
// Update recording indicators across all tabs
updateRecordingIndicators();
}
function updateRecordingIndicators() {
const isRecording = NoraRecorder.isRecordingInProgress();
const recordingConsultationID = isRecording ? NoraRecorder.getActiveConsultationID() : null;
// Update all tab indicators
document.querySelectorAll('.consultation-tab').forEach(tab => {
const tabConsultationID = tab.getAttribute('data-consultation-id');
const indicator = tab.querySelector('.recording-indicator');
if (recordingConsultationID === tabConsultationID) {
indicator.style.display = 'block';
indicator.classList.add('recording');
} else {
indicator.style.display = 'none';
indicator.classList.remove('recording');
}
});
}
// Listen for recording events to update indicators
document.addEventListener('nora-recorder-status', updateRecordingIndicators);
// Clean up all recorders when page unloads
window.addEventListener('beforeunload', () => {
consultationRecorders.forEach(recorder => recorder.cleanup());
});
Benefits of Pattern 2:
- Recording continues when switching between tabs
- Users can start recording on ConsultationA, switch to ConsultationB to check something, then return to ConsultationA
- Visual indicators show which consultation is recording across all tabs
- System prevents multiple simultaneous recordings (built-in safety)
Choose Pattern 1 if: Users complete recordings before switching tabs Choose Pattern 2 if: Users need to switch tabs during active recordings
When a user navigates between tabs in an EHR system:
- Clean up the current recorder completely before switching
- Initialize a new recorder for the new consultation
- Update UI indicators to show which tab has an active recording
- Note: Recordings cannot continue across tab switches with this pattern
When a user navigates between tabs in an EHR system:
- Show the target tab (no recorder cleanup needed)
- Update recording indicators across all tabs to show current recording status
- Preserve recording state across the switch (recording continues in background)
Use the global recording status to update UI indicators across tabs:
// Track which consultation is currently recording
let recordingConsultationID = null;
// Listen for recording status changes
document.addEventListener('nora-recorder-status', (event) => {
const { type, consultationID } = event.detail;
switch (type) {
case 'recording-started':
recordingConsultationID = consultationID;
updateTabIndicators(consultationID, 'recording');
break;
case 'recording-stopped':
case 'recording-discarded':
recordingConsultationID = null;
updateTabIndicators(null, 'stopped');
break;
}
});
function updateTabIndicators(recordingConsultationID, status) {
document.querySelectorAll('.consultation-tab').forEach(tab => {
const tabConsultationID = tab.getAttribute('data-consultation-id');
const indicator = tab.querySelector('.recording-indicator');
if (recordingConsultationID === tabConsultationID && status === 'recording') {
indicator.style.display = 'block';
indicator.className = 'recording-indicator active';
} else {
indicator.style.display = 'none';
indicator.className = 'recording-indicator';
}
});
}
// Check global recording status
function updateUIForCurrentTab() {
if (NoraRecorder.isRecordingInProgress()) {
const activeConsultationID = NoraRecorder.getActiveConsultationID();
updateTabIndicators(activeConsultationID, 'recording');
} else {
updateTabIndicators(null, 'stopped');
}
}
// Configuration
const API_CONFIG = {
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT'
};
// Track current state
let currentRecorder = null;
let currentConsultationID = null;
// Consultation tabs data
const consultations = {
'tab1': { id: 'CONSULT-001', patientName: 'John Doe' },
'tab2': { id: 'CONSULT-002', patientName: 'Jane Smith' },
'tab3': { id: 'CONSULT-003', patientName: 'Bob Wilson' }
};
// Switch between consultations
function switchToConsultation(consultationID, tabElement) {
// Update tab UI
document.querySelectorAll('.consultation-tab').forEach(tab => {
tab.classList.remove('active');
});
tabElement.classList.add('active');
// Skip if already on this consultation
if (currentConsultationID === consultationID && currentRecorder) {
console.log(`Already on consultation ${consultationID}`);
return;
}
console.log(`Switching to consultation ${consultationID}`);
// Clean up existing recorder
if (currentRecorder) {
currentRecorder.cleanup();
currentRecorder = null;
}
// Clear and prepare container
const container = document.getElementById('recorder-container');
container.innerHTML = '';
// Initialize recorder for new consultation
NoraRecorder.ready(function() {
NoraRecorder({
...API_CONFIG,
consultationID: consultationID,
container: container,
position: null,
snackbarPosition: "below-component"
}).then(recorder => {
currentRecorder = recorder;
currentConsultationID = consultationID;
// Update UI
updatePatientInfo(consultationID);
updateRecordingIndicators();
console.log(`✅ Recorder ready for ${consultationID}`);
}).catch(error => {
console.error(`❌ Error initializing recorder for ${consultationID}:`, error);
});
});
}
// Set up tab click handlers
document.querySelectorAll('.consultation-tab').forEach(tab => {
tab.addEventListener('click', () => {
const consultationID = tab.getAttribute('data-consultation-id');
switchToConsultation(consultationID, tab);
});
});
// Update recording indicators across tabs
function updateRecordingIndicators() {
const activeRecordingConsultationID = NoraRecorder.isRecordingInProgress()
? NoraRecorder.getActiveConsultationID()
: null;
document.querySelectorAll('.consultation-tab').forEach(tab => {
const tabConsultationID = tab.getAttribute('data-consultation-id');
const indicator = tab.querySelector('.recording-indicator');
if (activeRecordingConsultationID === tabConsultationID) {
indicator.style.display = 'block';
indicator.classList.add('recording');
} else {
indicator.style.display = 'none';
indicator.classList.remove('recording');
}
});
}
// Listen for recording events to update indicators
document.addEventListener('nora-recorder-status', (event) => {
updateRecordingIndicators();
});
// Initialize with first consultation
document.addEventListener('DOMContentLoaded', () => {
const firstTab = document.querySelector('.consultation-tab');
if (firstTab) {
const firstConsultationID = firstTab.getAttribute('data-consultation-id');
switchToConsultation(firstConsultationID, firstTab);
}
});
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (currentRecorder) {
currentRecorder.cleanup();
}
});
Initialize a NoraRecorder instance when a consultation view is loaded:
function onConsultationPageLoad(consultationData) {
// Wait for NoraRecorder to be ready
NoraRecorder.ready(function() {
NoraRecorder({
apiKey: 'YOUR_API_KEY',
doctorID: currentUser.id,
consultationID: consultationData.id,
apiBaseUrl: 'YOUR_API_ENDPOINT'
}).then(recorder => {
// Store reference for later cleanup
window.currentRecorder = recorder;
});
});
}
Clean up the recorder when a consultation view is unloaded:
function onConsultationPageUnload() {
// Clean up NoraRecorder resources
if (window.currentRecorder) {
NoraRecorder.cleanup();
window.currentRecorder = null;
}
}
For single-page applications (SPAs), handle recorder lifecycle during route changes:
// Example for a SPA router
router.beforeEach((to, from, next) => {
// Clean up previous recorder if navigating away from consultation
if (from.name === 'consultation' && to.name !== 'consultation') {
if (window.currentRecorder) {
NoraRecorder.cleanup();
window.currentRecorder = null;
}
}
next();
});
router.afterEach((to) => {
// Initialize new recorder if navigating to consultation
if (to.name === 'consultation') {
initializeRecorder(to.params.consultationId);
}
});
NoraRecorder emits events when recording status changes:
// Listen for recorder status events
document.addEventListener('nora-recorder-status', (event) => {
const { type, consultationID, doctorID, timestamp } = event.detail;
console.log(`Recording event: ${type} for consultation ${consultationID}`);
// Handle different event types
switch (type) {
case 'recording-started':
showRecordingIndicator(consultationID);
break;
case 'recording-stopped':
case 'recording-discarded':
hideRecordingIndicator(consultationID);
break;
case 'recording-paused':
updateRecordingIndicator(consultationID, 'paused');
break;
case 'recording-resumed':
updateRecordingIndicator(consultationID, 'recording');
break;
// Handle generate button events
case 'generation-started':
showGenerationIndicator(consultationID);
break;
case 'generation-stopped':
hideGenerationIndicator(consultationID);
break;
case 'generation-failed':
showGenerationError(consultationID);
break;
}
});
Note: The same event system is used for both recording and generate button functionality. When users click the Generate button (available for non-clinical task types when clinical notes exist), it emits generation-started
, generation-stopped
, and generation-failed
events using the same nora-recorder-status
event type.
The component dispatches a nora-recorder-status
event to both the container element and the document object. The event includes:
{
type: 'recording-started' | 'recording-stopped' | 'recording-paused' | 'recording-resumed' | 'recording-discarded' | 'generation-started' | 'generation-stopped' | 'generation-failed',
consultationID: 'UNIQUE_CONSULTATION_ID',
doctorID: 'DOCTOR_ID',
timestamp: 'ISO_TIMESTAMP'
}
You can listen at the document level (for all recorders) or on a specific recorder container:
// Listen on a specific container
const recorderContainer = document.getElementById('recorder-container');
recorderContainer.addEventListener('nora-recorder-status', (event) => {
// Handle event...
});
Use recording events to update tab indicators:
// Update tab UI based on recording status
function updateTabUI() {
const tabs = document.querySelectorAll('.patient-tab');
// Check if recording is active
if (NoraRecorder.isRecordingInProgress()) {
const activeConsultationID = NoraRecorder.getActiveConsultationID();
// Update tab indicators
tabs.forEach(tab => {
const tabConsultationID = tab.getAttribute('data-consultation-id');
if (tabConsultationID === activeConsultationID) {
tab.classList.add('recording-active');
tab.querySelector('.recording-indicator').style.display = 'inline-block';
} else {
tab.classList.remove('recording-active');
tab.querySelector('.recording-indicator').style.display = 'none';
}
});
} else {
// No active recording - remove all indicators
tabs.forEach(tab => {
tab.classList.remove('recording-active');
tab.querySelector('.recording-indicator').style.display = 'none';
});
}
}
// Call this function whenever tabs are rendered or when recording status changes
document.addEventListener('nora-recorder-status', updateTabUI);
Here's how you might implement recording indicators on tabs in a tabbed EHR system:
// Map of active recording consultations
let activeRecordingConsultationID = null;
// Listen for recorder status events
document.addEventListener('nora-recorder-status', (event) => {
const { type, consultationID } = event.detail;
// Handle recording started
if (type === 'recording-started') {
activeRecordingConsultationID = consultationID;
// Update all tabs to show recording indicator on the correct tab
document.querySelectorAll('.patient-tab').forEach(tab => {
const tabConsultationID = tab.getAttribute('data-consultation-id');
if (tabConsultationID === consultationID) {
tab.classList.add('recording');
tab.querySelector('.recording-indicator').style.display = 'block';
} else {
tab.classList.remove('recording');
tab.querySelector('.recording-indicator').style.display = 'none';
}
});
}
// Handle recording stopped or discarded
else if (type === 'recording-stopped' || type === 'recording-discarded') {
activeRecordingConsultationID = null;
// Remove recording indicators from all tabs
document.querySelectorAll('.patient-tab').forEach(tab => {
tab.classList.remove('recording');
tab.querySelector('.recording-indicator').style.display = 'none';
});
}
});
This implementation ensures users always know which consultation has an active recording, even when navigating between different patients or views in the EHR.
NoraRecorder provides a comprehensive event system for accessing generated content when recordings are processed and completed. This allows your EHR application to automatically receive and integrate the processed clinical notes.
The recorder emits nora-recorder-content
events when content becomes available:
Event Type | Description | When Triggered |
---|---|---|
content-ready |
Generated content is available | When processing completes successfully |
You can listen for content events at both the document level and container level:
// Listen at document level (recommended for global handling)
document.addEventListener('nora-recorder-content', (event) => {
const {
type,
consultationID,
doctorID,
content,
timestamp,
status,
processingStatus
} = event.detail;
console.log(`Content event: ${type}`);
console.log(`Consultation: ${consultationID}`);
console.log(`Generated content length: ${content.length}`);
// Handle the generated content
if (type === 'content-ready') {
insertContentIntoEHR(consultationID, content);
}
});
// Or listen on a specific recorder container
const recorderContainer = document.getElementById('recorder-container');
recorderContainer.addEventListener('nora-recorder-content', (event) => {
// Handle content for this specific recorder
handleGeneratedContent(event.detail);
});
Content events include comprehensive information about the generated content:
{
type: 'content-ready',
consultationID: 'CONSULTATION_ID',
doctorID: 'DOCTOR_ID',
timestamp: '2024-01-15T10:30:00.000Z',
status: 'COMPLETED',
processingStatus: 'sent', // or 'generated'
content: 'Chief Complaint: Patient presents with...' // The actual generated content
}
function setupContentListener() {
document.addEventListener('nora-recorder-content', (event) => {
const { type, consultationID, content } = event.detail;
if (type === 'content-ready') {
// Find the appropriate text area or editor for this consultation
const notesTextarea = document.querySelector(`[data-consultation-id="${consultationID}"] textarea`);
if (notesTextarea) {
// Insert the generated content
notesTextarea.value = content;
// Trigger any change events your EHR needs
notesTextarea.dispatchEvent(new Event('input', { bubbles: true }));
// Show success message
showNotification('Clinical notes generated successfully!', 'success');
}
}
});
}
// Initialize the listener when your application starts
setupContentListener();
function setupAdvancedContentHandler() {
document.addEventListener('nora-recorder-content', async (event) => {
const { type, consultationID, content, doctorID } = event.detail;
if (type === 'content-ready') {
try {
// Parse structured content if using templates
const structuredNotes = parseStructuredContent(content);
// Update different sections of the EHR
updateEHRSections(consultationID, structuredNotes);
// Save to your backend
await saveNotesToEHR({
consultationId: consultationID,
doctorId: doctorID,
notes: content,
timestamp: new Date().toISOString()
});
// Update UI to show content is saved
markConsultationAsCompleted(consultationID);
} catch (error) {
console.error('Error processing generated content:', error);
showNotification('Error saving generated notes', 'error');
}
}
});
}
function parseStructuredContent(content) {
// Example: Parse content based on your template structure
const sections = {
chiefComplaint: '',
historyOfPresentIllness: '',
assessment: '',
plan: ''
};
// Add your parsing logic here based on your templates
// This would depend on the structure of your generated content
return sections;
}
function updateEHRSections(consultationID, sections) {
// Update different form fields based on parsed content
Object.keys(sections).forEach(sectionKey => {
const field = document.querySelector(`[data-consultation-id="${consultationID}"] [data-field="${sectionKey}"]`);
if (field) {
field.value = sections[sectionKey];
}
});
}
import React, { useEffect, useState } from 'react';
const ConsultationNotesComponent = ({ consultationID }) => {
const [notes, setNotes] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
useEffect(() => {
const handleContentEvent = (event) => {
const { type, consultationID: eventConsultationID, content } = event.detail;
// Only handle events for this specific consultation
if (eventConsultationID === consultationID && type === 'content-ready') {
setNotes(content);
setIsProcessing(false);
// Optional: Auto-save to your backend
saveNotesToBackend(consultationID, content);
}
};
const handleStatusEvent = (event) => {
const { type, consultationID: eventConsultationID } = event.detail;
if (eventConsultationID === consultationID) {
if (type === 'recording-stopped') {
setIsProcessing(true);
}
}
};
// Listen for both content and status events
document.addEventListener('nora-recorder-content', handleContentEvent);
document.addEventListener('nora-recorder-status', handleStatusEvent);
return () => {
document.removeEventListener('nora-recorder-content', handleContentEvent);
document.removeEventListener('nora-recorder-status', handleStatusEvent);
};
}, [consultationID]);
const saveNotesToBackend = async (consultationId, content) => {
try {
await fetch('/api/consultations/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ consultationId, notes: content })
});
} catch (error) {
console.error('Error saving notes:', error);
}
};
return (
<div>
<h3>Clinical Notes</h3>
{isProcessing && <p>Processing recording...</p>}
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Clinical notes will appear here automatically..."
rows={15}
cols={80}
/>
{notes && (
<p className="success-message">
✓ Notes generated automatically from recording
</p>
)}
</div>
);
};
In addition to events, you can also programmatically check for and retrieve content:
// Initialize recorder and store reference
NoraRecorder({
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: 'CONSULTATION_ID',
apiBaseUrl: 'YOUR_API_ENDPOINT'
}).then(recorder => {
// Store reference for later use
window.currentRecorder = recorder;
// Check if content is already available (for existing recordings)
if (recorder.isContentReady()) {
const content = recorder.getGeneratedContent();
console.log('Existing content found:', content);
insertContentIntoEHR(content);
}
// Access subscription information
const subscriptionLevel = recorder.getSubscriptionLevel();
const monthlyUsageLeft = recorder.getMonthlyUsageLeft();
console.log('User subscription:', subscriptionLevel);
console.log('Monthly usage remaining:', monthlyUsageLeft);
// Apply subscription-based logic
if (subscriptionLevel === 'FreeTrial' && monthlyUsageLeft <= 5) {
showUpgradePrompt();
} else if (subscriptionLevel === 'Premium') {
enablePremiumFeatures();
}
});
// Later, you can check for content availability
function checkForGeneratedContent() {
if (window.currentRecorder && window.currentRecorder.isContentReady()) {
const content = window.currentRecorder.getGeneratedContent();
return content;
}
return null;
}
// Example subscription-based functionality
function checkSubscriptionLimits() {
if (window.currentRecorder) {
const usageLeft = window.currentRecorder.getMonthlyUsageLeft();
const subscriptionLevel = window.currentRecorder.getSubscriptionLevel();
if (usageLeft !== null && usageLeft <= 0) {
showUsageLimitReachedMessage(subscriptionLevel);
return false; // Prevent recording
}
if (usageLeft !== null && usageLeft <= 10) {
showLowUsageWarning(usageLeft, subscriptionLevel);
}
return true; // Allow recording
}
return true;
}
- Event-Driven Integration: Use events for real-time content integration rather than polling
-
Consultation-Specific Handling: Always check the
consultationID
to ensure content goes to the right place - Error Handling: Implement proper error handling for content processing and saving
- Auto-Save: Consider automatically saving generated content to your backend
- User Feedback: Provide clear feedback when content is being processed and when it's ready
- Content Validation: Validate generated content before inserting into critical EHR fields
For React applications, create a wrapper component:
import React, { useEffect, useRef, useState } from 'react';
import '@nora-technology/recorder/easy'; // Import the enhanced version
const NoraRecorderComponent = ({
apiKey,
doctorID,
consultationID,
apiBaseUrl,
embedded = true,
onRecorderReady,
customStyles = {} // Optional styling customizations
}) => {
const containerRef = useRef(null);
const recorderInstanceRef = useRef(null);
const [error, setError] = useState(null);
const [isInitialized, setIsInitialized] = useState(false);
// Default styles for the container
const defaultStyles = {
minHeight: '48px',
minWidth: '290px',
display: 'inline-block',
position: 'relative'
};
useEffect(() => {
let isMounted = true;
// Initialize when NoraRecorder is ready
window.NoraRecorder.ready(function() {
if (!isMounted) return;
window.NoraRecorder({
apiKey,
doctorID,
consultationID,
apiBaseUrl,
...(embedded ? {
container: containerRef.current,
position: null,
snackbarPosition: "below-component"
} : {
position: { x: 20, y: 20 }
})
}).then(recorder => {
if (!isMounted) {
// If component unmounted during initialization, clean up
recorder.cleanup();
return;
}
recorderInstanceRef.current = recorder;
setIsInitialized(true);
if (onRecorderReady) onRecorderReady(recorder);
}).catch(err => {
if (isMounted) {
setError(`Error initializing recorder: ${err.message || err}`);
}
});
});
// Handle loading errors
window.NoraRecorder.onError(function(error) {
if (isMounted) {
setError(`Failed to load NoraRecorder: ${error.message || error}`);
}
});
// Cleanup on unmount
return () => {
isMounted = false;
if (recorderInstanceRef.current) {
try {
window.NoraRecorder.cleanup();
recorderInstanceRef.current = null;
} catch (err) {
console.error('Error cleaning up recorder:', err);
}
}
};
}, [apiKey, doctorID, consultationID, apiBaseUrl, embedded, onRecorderReady]);
return (
<div>
{error && <div className="recorder-error">{error}</div>}
<div
ref={containerRef}
className="nora-recorder-container"
style={{...defaultStyles, ...customStyles}}
/>
</div>
);
};
export default NoraRecorderComponent;
For projects still using class components:
import React, { Component } from 'react';
import '@nora-technology/recorder';
class NoraRecorderComponent extends Component {
constructor(props) {
super(props);
this.state = {
isInitialized: false,
error: null
};
// Create refs
this.containerRef = React.createRef();
this.recorder = null;
}
componentDidMount() {
this.initializeRecorder();
}
componentDidUpdate(prevProps) {
// Re-initialize if key props change
if (
prevProps.consultationID !== this.props.consultationID ||
prevProps.doctorID !== this.props.doctorID ||
prevProps.apiKey !== this.props.apiKey ||
prevProps.apiBaseUrl !== this.props.apiBaseUrl
) {
this.cleanupRecorder();
this.initializeRecorder();
}
}
componentWillUnmount() {
this.cleanupRecorder();
}
initializeRecorder = () => {
const {
apiKey,
doctorID,
consultationID,
apiBaseUrl,
embedded = true
} = this.props;
// Make sure the global NoraRecorder object is available
if (typeof window.NoraRecorder === 'undefined') {
this.setState({ error: 'NoraRecorder script not loaded' });
return;
}
// Initialize when NoraRecorder is ready
window.NoraRecorder.ready(() => {
window.NoraRecorder({
apiKey,
doctorID,
consultationID,
apiBaseUrl,
...(embedded ? {
container: this.containerRef.current,
position: null,
snackbarPosition: "below-component"
} : {
position: { x: 20, y: 20 }
})
}).then(recorder => {
this.recorder = recorder;
this.setState({ isInitialized: true, error: null });
if (this.props.onRecorderReady) {
this.props.onRecorderReady(recorder);
}
}).catch(err => {
this.setState({
error: `Error initializing recorder: ${err.message || err}`
});
});
});
}
cleanupRecorder = () => {
if (this.recorder) {
try {
this.recorder.cleanup();
this.recorder = null;
} catch (err) {
console.error('Error cleaning up recorder:', err);
}
}
}
render() {
const { error } = this.state;
const { customStyles = {} } = this.props;
const defaultStyles = {
minHeight: '48px',
minWidth: '290px',
display: 'inline-block',
position: 'relative'
};
return (
<div>
{error && <div className="recorder-error">{error}</div>}
<div
ref={this.containerRef}
className="nora-recorder-container"
style={{...defaultStyles, ...customStyles}}
/>
</div>
);
}
}
export default NoraRecorderComponent;
For larger applications, React Context provides a clean way to share NoraRecorder configuration:
// NoraRecorderContext.js
import React, { createContext, useContext } from 'react';
const NoraRecorderContext = createContext(null);
export function NoraRecorderProvider({
children,
apiKey,
doctorID,
apiBaseUrl
}) {
const contextValue = {
apiKey,
doctorID,
apiBaseUrl
};
return (
<NoraRecorderContext.Provider value={contextValue}>
{children}
</NoraRecorderContext.Provider>
);
}
export function useNoraRecorder() {
const context = useContext(NoraRecorderContext);
if (!context) {
throw new Error('useNoraRecorder must be used within a NoraRecorderProvider');
}
return context;
}
// Usage in App.js
import React from 'react';
import { NoraRecorderProvider } from './NoraRecorderContext';
function App({ currentUser }) {
return (
<NoraRecorderProvider
apiKey="YOUR_API_KEY"
doctorID={currentUser.id}
apiBaseUrl="YOUR_API_ENDPOINT"
>
<YourRoutes />
</NoraRecorderProvider>
);
}
// Usage in ConsultationPage.js
import React from 'react';
import { useNoraRecorder } from './NoraRecorderContext';
import NoraRecorderComponent from './NoraRecorderComponent';
function ConsultationPage({ consultation }) {
const { apiKey, doctorID, apiBaseUrl } = useNoraRecorder();
return (
<div className="consultation-page">
<h1>Patient: {consultation.patientName}</h1>
<NoraRecorderComponent
apiKey={apiKey}
doctorID={doctorID}
consultationID={consultation.id}
apiBaseUrl={apiBaseUrl}
embedded={true}
/>
<textarea placeholder="Notes will appear here..."></textarea>
</div>
);
}
For React applications that need to support multiple task types, proper prop handling and component re-initialization is crucial. Here's how to implement task type switching in React:
import React, { useEffect, useRef, useState, useCallback } from 'react';
const NoraRecorderComponent = ({
apiKey,
doctorID,
consultationID,
apiBaseUrl,
taskType = 'clinical_notes', // Add taskType prop
embedded = true,
onRecorderReady,
onContentReady,
customStyles = {}
}) => {
const containerRef = useRef(null);
const recorderInstanceRef = useRef(null);
const [error, setError] = useState(null);
const [isInitialized, setIsInitialized] = useState(false);
const [currentTaskType, setCurrentTaskType] = useState(taskType);
// Default styles for the container
const defaultStyles = {
minHeight: '48px',
minWidth: '290px',
display: 'inline-block',
position: 'relative'
};
// Cleanup function
const cleanupRecorder = useCallback(() => {
if (recorderInstanceRef.current) {
try {
recorderInstanceRef.current.cleanup();
recorderInstanceRef.current = null;
setIsInitialized(false);
} catch (err) {
console.error('Error cleaning up recorder:', err);
}
}
}, []);
// Initialize recorder function
const initializeRecorder = useCallback(() => {
if (!window.NoraRecorder) {
setError('NoraRecorder not available');
return;
}
// Clear container before initializing
if (containerRef.current) {
containerRef.current.innerHTML = '';
}
window.NoraRecorder.ready(function() {
window.NoraRecorder({
apiKey,
doctorID,
consultationID,
apiBaseUrl,
taskType, // Pass the taskType prop
...(embedded ? {
container: containerRef.current,
position: null,
snackbarPosition: "below-component"
} : {
position: { x: 20, y: 20 }
})
}).then(recorder => {
recorderInstanceRef.current = recorder;
setIsInitialized(true);
setCurrentTaskType(taskType);
setError(null);
if (onRecorderReady) {
onRecorderReady(recorder, taskType);
}
}).catch(err => {
setError(`Error initializing recorder: ${err.message || err}`);
});
});
}, [apiKey, doctorID, consultationID, apiBaseUrl, taskType, embedded, onRecorderReady]);
// Effect for initial mount and when key props change
useEffect(() => {
let isMounted = true;
const initialize = () => {
if (isMounted) {
initializeRecorder();
}
};
// Handle loading errors
const handleError = (error) => {
if (isMounted) {
setError(`Failed to load NoraRecorder: ${error.message || error}`);
}
};
if (window.NoraRecorder) {
initialize();
} else {
// Wait for NoraRecorder to be available
window.NoraRecorder?.ready?.(initialize);
window.NoraRecorder?.onError?.(handleError);
}
// Cleanup on unmount
return () => {
isMounted = false;
cleanupRecorder();
};
}, []); // Only run on mount
// Effect for handling prop changes (especially taskType)
useEffect(() => {
// If taskType changed, reinitialize the recorder
if (isInitialized && taskType !== currentTaskType) {
console.log(`Task type changed from ${currentTaskType} to ${taskType}, reinitializing recorder`);
cleanupRecorder();
// Small delay to ensure cleanup is complete
setTimeout(() => {
initializeRecorder();
}, 100);
}
}, [taskType, currentTaskType, isInitialized, cleanupRecorder, initializeRecorder]);
// Effect for handling other prop changes
useEffect(() => {
// If other critical props changed, reinitialize
if (isInitialized) {
cleanupRecorder();
setTimeout(() => {
initializeRecorder();
}, 100);
}
}, [apiKey, doctorID, consultationID, apiBaseUrl, embedded]);
// Listen for content events
useEffect(() => {
const handleContentEvent = (event) => {
const { type, consultationID: eventConsultationID, content, taskType: eventTaskType } = event.detail;
// Only handle events for this specific consultation and task type
if (eventConsultationID === consultationID &&
type === 'content-ready' &&
eventTaskType === taskType) {
if (onContentReady) {
onContentReady(content, eventTaskType, consultationID);
}
}
};
document.addEventListener('nora-recorder-content', handleContentEvent);
return () => {
document.removeEventListener('nora-recorder-content', handleContentEvent);
};
}, [consultationID, taskType, onContentReady]);
return (
<div>
{error && <div className="recorder-error" style={{ color: '#f44336', marginBottom: '5px' }}>{error}</div>}
<div
ref={containerRef}
className="nora-recorder-container"
style={{...defaultStyles, ...customStyles}}
data-task-type={taskType} // Add data attribute for debugging
/>
</div>
);
};
export default NoraRecorderComponent;
Here's how to implement task type switching in a React application:
import React, { useState, useCallback } from 'react';
import NoraRecorderComponent from './NoraRecorderComponent';
const ConsultationPage = ({ consultation }) => {
const [selectedTaskType, setSelectedTaskType] = useState('clinical_notes');
const [generatedContent, setGeneratedContent] = useState({});
const [recorderInstances, setRecorderInstances] = useState({});
// Task type configuration
const taskTypes = [
{ id: 'clinical_notes', label: 'Clinical Notes', description: 'Standard consultation notes' },
{ id: 'referral_letter', label: 'Referral Letter', description: 'Letters referring patients to specialists' },
{ id: 'sick_note', label: 'Sick Note', description: 'Medical certificates for sick leave' },
{ id: 'patient_notes', label: 'Patient Notes', description: 'General patient documentation' },
{ id: 'vitals', label: 'Vitals', description: 'Recording of vital signs and measurements' }
];
// Handle task type selection
const handleTaskTypeChange = useCallback((newTaskType) => {
console.log(`Switching from ${selectedTaskType} to ${newTaskType}`);
setSelectedTaskType(newTaskType);
}, [selectedTaskType]);
// Handle recorder ready
const handleRecorderReady = useCallback((recorder, taskType) => {
console.log(`Recorder ready for task type: ${taskType}`);
setRecorderInstances(prev => ({
...prev,
[taskType]: recorder
}));
}, []);
// Handle content ready
const handleContentReady = useCallback((content, taskType, consultationID) => {
console.log(`Content ready for ${taskType}: ${content.length} characters`);
// Store content by task type
setGeneratedContent(prev => ({
...prev,
[taskType]: {
content,
timestamp: new Date().toISOString(),
consultationID
}
}));
// In a real application, you would also save this to your backend
// saveContentToBackend(consultationID, taskType, content);
}, []);
return (
<div className="consultation-page">
<h1>Patient: {consultation.patientName}</h1>
<p>Consultation ID: {consultation.id}</p>
{/* Task Type Selector */}
<div className="task-type-selector" style={{ marginBottom: '20px' }}>
<h3>Select Documentation Type</h3>
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
{taskTypes.map(taskType => (
<button
key={taskType.id}
onClick={() => handleTaskTypeChange(taskType.id)}
style={{
padding: '10px 15px',
border: `2px solid ${selectedTaskType === taskType.id ? '#4f46e5' : '#ddd'}`,
backgroundColor: selectedTaskType === taskType.id ? '#4f46e5' : 'white',
color: selectedTaskType === taskType.id ? 'white' : 'black',
borderRadius: '5px',
cursor: 'pointer',
transition: 'all 0.3s ease'
}}
title={taskType.description}
>
{taskType.label}
</button>
))}
</div>
<p style={{ marginTop: '10px', color: '#666' }}>
Current: <strong>{taskTypes.find(t => t.id === selectedTaskType)?.label}</strong>
</p>
</div>
{/* NoraRecorder Component */}
<div className="recorder-section" style={{ marginBottom: '20px', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<h3>Voice Recorder</h3>
<p>Recording for: <strong>{taskTypes.find(t => t.id === selectedTaskType)?.label}</strong></p>
<NoraRecorderComponent
apiKey="YOUR_API_KEY"
doctorID="DOCTOR_ID"
consultationID={consultation.id}
apiBaseUrl="YOUR_API_ENDPOINT"
taskType={selectedTaskType} // This prop change triggers recorder reinitialization
embedded={true}
onRecorderReady={handleRecorderReady}
onContentReady={handleContentReady}
/>
</div>
{/* Content Display Areas */}
<div className="content-sections">
<h3>Generated Content</h3>
{taskTypes.map(taskType => {
const content = generatedContent[taskType.id];
const hasContent = content && content.content;
return (
<div
key={taskType.id}
className="content-section"
style={{
marginBottom: '15px',
padding: '15px',
border: '1px solid #ddd',
borderRadius: '8px',
backgroundColor: hasContent ? '#f0f8ff' : '#f9f9f9'
}}
>
<h4>
{taskType.label}
{hasContent && <span style={{ color: '#10B981', marginLeft: '10px' }}>✓</span>}
</h4>
<textarea
value={hasContent ? content.content : ''}
onChange={(e) => {
// Allow manual editing
setGeneratedContent(prev => ({
...prev,
[taskType.id]: {
...prev[taskType.id],
content: e.target.value
}
}));
}}
placeholder={`${taskType.label} content will appear here...`}
rows={6}
style={{
width: '100%',
border: '1px solid #ccc',
borderRadius: '4px',
padding: '10px',
fontFamily: 'monospace',
fontSize: '14px'
}}
/>
{hasContent && (
<p style={{ fontSize: '12px', color: '#666', marginTop: '5px' }}>
Generated: {new Date(content.timestamp).toLocaleString()}
</p>
)}
</div>
);
})}
</div>
</div>
);
};
export default ConsultationPage;
-
Prop-Driven Reinitialization: When the
taskType
prop changes, the component automatically cleans up the old recorder and initializes a new one with the correct task type. -
Proper Cleanup: Always clean up the previous recorder instance before creating a new one to prevent memory leaks and conflicts.
-
Event Filtering: Content events are filtered by both
consultationID
andtaskType
to ensure content goes to the right place. -
State Management: Use React state to track the current task type and generated content for each task type.
-
UI Updates: The recorder UI automatically updates based on the task type:
- Clinical Notes: Shows template selection, mode toggle, language, and microphone selection
- Other Task Types: Shows simplified interface with task type display, language, and microphone selection
For more complex applications, consider these patterns:
// Custom hook for NoraRecorder management
const useNoraRecorder = (consultationID, taskType) => {
const [recorder, setRecorder] = useState(null);
const [content, setContent] = useState(null);
const [isReady, setIsReady] = useState(false);
const handleRecorderReady = useCallback((recorderInstance) => {
setRecorder(recorderInstance);
setIsReady(true);
}, []);
const handleContentReady = useCallback((generatedContent) => {
setContent(generatedContent);
}, []);
return {
recorder,
content,
isReady,
handleRecorderReady,
handleContentReady
};
};
// Usage in component
const ConsultationPage = ({ consultation }) => {
const [taskType, setTaskType] = useState('clinical_notes');
const { recorder, content, isReady, handleRecorderReady, handleContentReady } = useNoraRecorder(
consultation.id,
taskType
);
return (
<div>
{/* Task type selector */}
<TaskTypeSelector value={taskType} onChange={setTaskType} />
{/* Recorder component */}
<NoraRecorderComponent
consultationID={consultation.id}
taskType={taskType}
onRecorderReady={handleRecorderReady}
onContentReady={handleContentReady}
/>
{/* Content display */}
{content && <ContentDisplay content={content} taskType={taskType} />}
</div>
);
};
-
Single Consultation ID: Use the same
consultationID
for all task types within a single patient visit, but pass differenttaskType
props to create different types of documentation. -
Component Reinitialization: The NoraRecorder component must be reinitialized when the
taskType
prop changes because:- The UI needs to update (clinical notes shows full interface, others show simplified interface)
- The API endpoint changes based on task type
- Different headers are sent based on task type
-
Content Routing: Use the
taskType
in content events to route generated content to the appropriate text areas or form fields in your EHR. -
State Management: Maintain separate state for each task type's content, allowing users to switch between task types without losing previously generated content.
-
Error Handling: Implement proper error handling for task type switching, including cleanup of failed initializations.
This React integration pattern ensures that:
- The recorder UI updates correctly when task types change
- Content is properly routed to the correct areas
- The component handles cleanup and reinitialization seamlessly
- The application maintains proper state management for multiple task types
For Angular applications:
// nora-recorder.component.ts
import {
Component,
OnInit,
OnDestroy,
Input,
ElementRef,
ViewChild,
Output,
EventEmitter,
OnChanges,
SimpleChanges
} from '@angular/core';
@Component({
selector: 'app-nora-recorder',
template: `
<div class="nora-recorder-wrapper">
<div *ngIf="error" class="recorder-error">{{ error }}</div>
<div #recorderContainer class="recorder-container"></div>
</div>
`,
styles: [`
.recorder-container {
min-height: 48px;
min-width: 290px;
display: inline-block;
position: relative;
}
.recorder-error {
color: #f44336;
margin-bottom: 5px;
}
`]
})
export class NoraRecorderComponent implements OnInit, OnDestroy, OnChanges {
@Input() apiKey: string;
@Input() doctorID: string;
@Input() consultationID: string;
@Input() apiBaseUrl: string;
@Input() embedded: boolean = true;
@Input() position: { x: number, y: number } = { x: 20, y: 20 };
@Output() recorderReady = new EventEmitter<any>();
@ViewChild('recorderContainer', { static: true }) recorderContainer: ElementRef;
private recorder: any = null;
private scriptLoaded: boolean = false;
public error: string = null;
constructor() { }
ngOnInit(): void {
this.loadScript();
}
ngOnChanges(changes: SimpleChanges): void {
// Reinitialize if key properties change
if (
(changes.apiKey && !changes.apiKey.firstChange) ||
(changes.doctorID && !changes.doctorID.firstChange) ||
(changes.consultationID && !changes.consultationID.firstChange) ||
(changes.apiBaseUrl && !changes.apiBaseUrl.firstChange) ||
(changes.embedded && !changes.embedded.firstChange)
) {
this.cleanupRecorder();
if (this.scriptLoaded) {
this.initializeRecorder();
} else {
this.loadScript();
}
}
}
ngOnDestroy(): void {
this.cleanupRecorder();
}
private loadScript(): void {
// Check if script is already loaded
if (window['NoraRecorder']) {
this.scriptLoaded = true;
this.initializeRecorder();
return;
}
// Create script element to load NoraRecorder
const script = document.createElement('script');
script.type = 'text/javascript';
script.src = 'https://unpkg.com/@nora-technology/recorder@1.0.29/dist/nora-recorder-easy.js';
script.async = true;
script.onload = () => {
this.scriptLoaded = true;
this.initializeRecorder();
};
script.onerror = () => {
this.error = 'Failed to load NoraRecorder script';
};
document.body.appendChild(script);
}
private initializeRecorder(): void {
if (!this.scriptLoaded || !window['NoraRecorder']) {
this.error = 'NoraRecorder script not loaded';
return;
}
if (!this.apiKey || !this.doctorID || !this.consultationID || !this.apiBaseUrl) {
this.error = 'Missing required parameters for NoraRecorder';
return;
}
// Initialize recorder when ready
window['NoraRecorder'].ready(() => {
window['NoraRecorder']({
apiKey: this.apiKey,
doctorID: this.doctorID,
consultationID: this.consultationID,
apiBaseUrl: this.apiBaseUrl,
...(this.embedded ? {
container: this.recorderContainer.nativeElement,
position: null,
snackbarPosition: "below-component"
} : {
position: this.position
})
}).then(recorder => {
this.recorder = recorder;
this.error = null;
this.recorderReady.emit(recorder);
}).catch(err => {
this.error = `Error initializing recorder: ${err.message || err}`;
});
});
}
private cleanupRecorder(): void {
if (this.recorder) {
try {
this.recorder.cleanup();
this.recorder = null;
} catch (err) {
console.error('Error cleaning up recorder:', err);
}
}
}
}
Register the component in an Angular module:
// nora-recorder.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NoraRecorderComponent } from './nora-recorder.component';
@NgModule({
declarations: [
NoraRecorderComponent
],
imports: [
CommonModule
],
exports: [
NoraRecorderComponent
]
})
export class NoraRecorderModule { }
For larger applications, create a service to manage recorders across the application:
// nora-recorder.service.ts
import { Injectable } from '@angular/core';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class NoraRecorderService {
private activeRecorders: Map<string, any> = new Map();
// Configuration that's consistent across the app
private apiKey = environment.noraApiKey;
private apiBaseUrl = environment.noraApiBaseUrl;
constructor() { }
// Register a recorder instance for a consultation
registerRecorder(consultationId: string, recorder: any): void {
this.activeRecorders.set(consultationId, recorder);
}
// Get a specific recorder by consultation ID
getRecorder(consultationId: string): any {
return this.activeRecorders.get(consultationId);
}
// Unregister and clean up a recorder
unregisterRecorder(consultationId: string): void {
const recorder = this.activeRecorders.get(consultationId);
if (recorder) {
try {
recorder.cleanup();
} catch (err) {
console.error('Error cleaning up recorder:', err);
}
this.activeRecorders.delete(consultationId);
}
}
// Check if a recording is in progress anywhere
isRecordingInProgress(): boolean {
return typeof window['NoraRecorder'] !== 'undefined' &&
window['NoraRecorder'].isRecordingInProgress();
}
// Get the consultation ID where recording is happening
getActiveConsultationID(): string | null {
if (typeof window['NoraRecorder'] !== 'undefined') {
return window['NoraRecorder'].getActiveConsultationID();
}
return null;
}
// Get configuration for NoraRecorder
getConfiguration(doctorId: string, consultationId: string) {
return {
apiKey: this.apiKey,
doctorID: doctorId,
consultationID: consultationId,
apiBaseUrl: this.apiBaseUrl
};
}
// Clean up all recorders when application shuts down
cleanupAll(): void {
this.activeRecorders.forEach((recorder, consultationId) => {
try {
recorder.cleanup();
} catch (err) {
console.error(`Error cleaning up recorder for ${consultationId}:`, err);
}
});
this.activeRecorders.clear();
}
}
// consultation.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { NoraRecorderService } from '../services/nora-recorder.service';
import { UserService } from '../services/user.service';
@Component({
selector: 'app-consultation',
template: `
<div class="consultation-page">
<h1>Patient: {{ consultation?.patientName }}</h1>
<app-nora-recorder
[apiKey]="recorderConfig.apiKey"
[doctorID]="recorderConfig.doctorID"
[consultationID]="recorderConfig.consultationID"
[apiBaseUrl]="recorderConfig.apiBaseUrl"
(recorderReady)="onRecorderReady($event)"
></app-nora-recorder>
<textarea
placeholder="Notes will appear here..."
rows="10"
cols="60"
></textarea>
</div>
`
})
export class ConsultationComponent implements OnInit, OnDestroy {
recorderConfig: any;
consultationId: string;
consultation: any;
constructor(
private recorderService: NoraRecorderService,
private userService: UserService,
private route: ActivatedRoute
) { }
ngOnInit(): void {
// Get consultation ID from route
this.consultationId = this.route.snapshot.paramMap.get('id');
const currentUser = this.userService.getCurrentUser();
// Get centralized configuration
this.recorderConfig = this.recorderService.getConfiguration(
currentUser.id,
this.consultationId
);
// Fetch consultation data
this.fetchConsultationData();
}
fetchConsultationData(): void {
// Implementation to fetch consultation data
}
onRecorderReady(recorder: any): void {
// Register with service for centralized management
this.recorderService.registerRecorder(this.consultationId, recorder);
}
ngOnDestroy(): void {
// Clean up when navigating away
this.recorderService.unregisterRecorder(this.consultationId);
}
}
For Vue applications:
<!-- NoraRecorder.vue -->
<template>
<div class="nora-recorder-wrapper">
<div v-if="error" class="recorder-error">{{ error }}</div>
<div ref="recorderContainer" class="recorder-container"></div>
</div>
</template>
<script>
export default {
name: 'NoraRecorder',
props: {
apiKey: { type: String, required: true },
doctorId: { type: String, required: true },
consultationId: { type: String, required: true },
apiBaseUrl: { type: String, required: true },
embedded: { type: Boolean, default: true }
},
data() {
return {
error: null,
recorder: null
};
},
mounted() {
this.initializeRecorder();
},
methods: {
initializeRecorder() {
// Wait for NoraRecorder to be ready
window.NoraRecorder.ready(() => {
window.NoraRecorder({
apiKey: this.apiKey,
doctorID: this.doctorId,
consultationID: this.consultationId,
apiBaseUrl: this.apiBaseUrl,
...(this.embedded ? {
container: this.$refs.recorderContainer,
position: null,
snackbarPosition: "below-component"
} : {
position: { x: 20, y: 20 }
})
}).then(recorder => {
this.recorder = recorder;
this.$emit('recorder-ready', recorder);
}).catch(err => {
this.error = `Error initializing recorder: ${err.message || err}`;
});
});
}
},
beforeDestroy() {
// Clean up resources
if (this.recorder) {
window.NoraRecorder.cleanup();
}
}
}
</script>
<style scoped>
.recorder-container {
min-height: 48px;
min-width: 290px;
display: inline-block;
position: relative;
}
.recorder-error {
color: #f44336;
margin-bottom: 5px;
}
</style>
Method | Description | Parameters | Return Value |
---|---|---|---|
NoraRecorder.ready(callback) |
Register a callback for when NoraRecorder is ready | Function | void |
NoraRecorder.onError(callback) |
Register a callback for loading errors | Function | void |
NoraRecorder(options) |
Initialize recorder with options | Object | Promise |
NoraRecorder.cleanup() |
Clean up resources and destroy recorder | None | void |
NoraRecorder.getInstance() |
Get the current active recorder instance | None | Recorder or null |
NoraRecorder.getLoadState() |
Get the current loading state of NoraRecorder | None | String ('loading', 'ready', 'error') |
NoraRecorder.isRecordingInProgress() |
Check if any recorder is recording | None | Boolean |
NoraRecorder.getActiveConsultationID() |
Get the active consultation ID | None | String or null |
NoraRecorder.version |
Get the version string | None | String |
Method | Description | Parameters | Return Value |
---|---|---|---|
recorder.handleMicButtonClick() |
Toggle recording state | None | void |
recorder.stopRecording() |
Stop current recording | None | void |
recorder.discardRecording() |
Discard current recording | None | void |
recorder.performDelete() |
Delete recording with confirmation dialog | None | void |
recorder.getGeneratedContent() |
Get generated content if available | None | String or null |
recorder.isContentReady() |
Check if content is ready | None | Boolean |
recorder.getSubscriptionLevel() |
Get user's subscription level | None | String or null |
recorder.getMonthlyUsageLeft() |
Get user's monthly usage left | None | Number or null |
recorder.updateMonthlyUsageLeft(newUsageLeft) |
Update monthly usage and refresh UI | Number | void |
recorder.cleanup() |
Clean up resources | None | void |
Global Utility Methods:
// Get the current active recorder instance
const currentRecorder = NoraRecorder.getInstance();
if (currentRecorder) {
console.log('Recorder is active for consultation:', currentRecorder.consultationID);
} else {
console.log('No active recorder found');
}
// Check loading state before initialization
const loadState = NoraRecorder.getLoadState();
if (loadState === 'ready') {
// Safe to initialize recorder
NoraRecorder({ /* options */ });
} else if (loadState === 'loading') {
// Wait for ready callback
NoraRecorder.ready(() => {
NoraRecorder({ /* options */ });
});
} else if (loadState === 'error') {
console.error('NoraRecorder failed to load');
}
Instance Management Methods:
// Delete current recording with user confirmation
// This shows a confirmation dialog before deleting
recorder.performDelete();
// Update usage tracking (useful for real-time usage displays)
recorder.updateMonthlyUsageLeft(120); // 120 minutes remaining
// Check subscription and usage status
const subscriptionLevel = recorder.getSubscriptionLevel();
const usageLeft = recorder.getMonthlyUsageLeft();
if (subscriptionLevel === 'Lite' && usageLeft < 60) {
// Show upgrade prompt when usage is low
showUpgradePrompt(`You have ${usageLeft} minutes remaining this month`);
}
Complete Integration Example:
// EHR integration showing proper instance management
let currentRecorder = null;
function initializeRecorderForConsultation(consultationID) {
// Check if recorder is already loaded
if (NoraRecorder.getLoadState() !== 'ready') {
console.log('Waiting for NoraRecorder to load...');
NoraRecorder.ready(() => initializeRecorderForConsultation(consultationID));
return;
}
// Clean up existing recorder
if (currentRecorder) {
currentRecorder.cleanup();
currentRecorder = null;
}
// Initialize new recorder
NoraRecorder({
apiKey: 'YOUR_API_KEY',
doctorID: 'DOCTOR_ID',
consultationID: consultationID,
apiBaseUrl: 'YOUR_API_ENDPOINT',
container: document.getElementById('recorder-container')
}).then(recorder => {
currentRecorder = recorder;
// Check usage and subscription status
const usageLeft = recorder.getMonthlyUsageLeft();
const subscription = recorder.getSubscriptionLevel();
updateUsageDisplay(usageLeft, subscription);
console.log('Recorder ready for consultation:', consultationID);
}).catch(error => {
console.error('Failed to initialize recorder:', error);
});
}
function deleteCurrentRecording() {
const recorder = NoraRecorder.getInstance();
if (recorder) {
// This will show confirmation dialog
recorder.performDelete();
} else {
console.log('No active recorder to delete from');
}
}
function updateUsageDisplay(usage, subscription) {
const usageElement = document.getElementById('usage-display');
if (usageElement && usage !== null) {
usageElement.textContent = `${usage} minutes remaining (${subscription} plan)`;
}
}
Option | Type | Description | Default |
---|---|---|---|
apiKey |
string | Authentication key (required) | - |
doctorID |
string | Doctor ID (required) | - |
consultationID |
string | Consultation ID (required) | - |
apiBaseUrl |
string | API base URL (required) | - |
taskType |
string | Type of task: "clinical_notes" , "referral_letter" , "sick_note" , "patient_notes" , "vitals"
|
"clinical_notes" |
position |
object | Position of recorder button {x, y}
|
{ x: 20, y: 20 } |
size |
number | Size of the recorder button in pixels | 60 |
primaryColor |
string | Primary color (hex code) | "#4f46e5" |
secondaryColor |
string | Secondary color (hex code) | "#ffffff" |
snackbarPosition |
string | Position of notifications: "bottom-center" or "below-component"
|
"bottom-center" |
contextPosition |
string | Position of context recommendations: "below" , "right" , "left"
|
"below" |
enableContextChecker |
boolean | Enable/disable context recommendations system | true |
container |
HTMLElement | Container element for embedded mode | null |
prePromptCallback |
function | Optional callback to provide context from EHR | null |
Event Type | Description | When Triggered |
---|---|---|
recording-started |
Recording has begun | When user starts recording |
recording-stopped |
Recording has ended | When user stops recording |
recording-paused |
Recording has been paused | When user pauses recording |
recording-resumed |
Recording has been resumed | When user resumes recording |
recording-discarded |
Recording has been discarded | When user cancels recording |
generation-started |
Generate button processing has begun | When user clicks Generate button |
generation-stopped |
Generate button processing has completed | When generation upload finishes |
generation-failed |
Generate button processing has failed | When generation encounters an error |
Event Type | Description | When Triggered |
---|---|---|
content-ready |
Generated content is available | When processing completes and content is ready |
{
type: 'recording-started' | 'recording-stopped' | 'recording-paused' | 'recording-resumed' | 'recording-discarded' | 'generation-started' | 'generation-stopped' | 'generation-failed',
consultationID: 'UNIQUE_CONSULTATION_ID',
doctorID: 'DOCTOR_ID',
taskType: 'clinical_notes' | 'referral_letter' | 'sick_note' | 'patient_notes' | 'vitals',
timestamp: 'ISO_TIMESTAMP'
}
{
type: 'content-ready',
consultationID: 'UNIQUE_CONSULTATION_ID',
doctorID: 'DOCTOR_ID',
taskType: 'clinical_notes' | 'referral_letter' | 'sick_note' | 'patient_notes' | 'vitals',
timestamp: 'ISO_TIMESTAMP',
status: 'COMPLETED',
processingStatus: 'sent' | 'generated',
content: 'Generated clinical notes content...'
}
The taskType
field is critical for properly routing content and handling events in your EHR application:
Purpose:
- Identifies the type of documentation being generated
- Allows your EHR to route content to the appropriate text areas or form fields
- Enables task-type-specific handling and validation
Expected Values:
-
'clinical_notes'
- Standard consultation notes (default) -
'referral_letter'
- Letters referring patients to specialists -
'sick_note'
- Medical certificates for sick leave -
'patient_notes'
- General patient documentation -
'vitals'
- Recording of vital signs and measurements
Troubleshooting taskType: undefined
:
If you see taskType: undefined
in event details, this indicates an initialization issue:
-
Most Common Cause: The
taskType
option was not passed during NoraRecorder initialization// ❌ Missing taskType - will default but may show undefined in some cases NoraRecorder({ apiKey: 'YOUR_API_KEY', doctorID: 'DOCTOR_ID', consultationID: 'CONSULTATION_ID', apiBaseUrl: 'YOUR_API_ENDPOINT' // Missing taskType parameter }) // ✅ Correct - explicitly specify taskType NoraRecorder({ apiKey: 'YOUR_API_KEY', doctorID: 'DOCTOR_ID', consultationID: 'CONSULTATION_ID', apiBaseUrl: 'YOUR_API_ENDPOINT', taskType: 'clinical_notes' // Explicitly set })
-
Check Your Integration: Ensure your integration code passes the
taskType
parameter:// Example for React components <NoraRecorderComponent apiKey="YOUR_API_KEY" doctorID="DOCTOR_ID" consultationID={consultation.id} apiBaseUrl="YOUR_API_ENDPOINT" taskType="clinical_notes" // Make sure this prop is passed />
-
Debugging: Enable console logging to trace the issue:
// The recorder will log taskType initialization in debug mode window.NORA_RECORDER_DEBUG = true;
Event Filtering by Task Type:
Use the taskType
field to filter events for specific documentation types:
document.addEventListener('nora-recorder-content', (event) => {
const { type, consultationID, content, taskType } = event.detail;
// Filter by task type
if (type === 'content-ready') {
switch (taskType) {
case 'clinical_notes':
// Route to clinical notes field
document.getElementById('clinical-notes-textarea').value = content;
break;
case 'referral_letter':
// Route to referral letter field
document.getElementById('referral-letter-textarea').value = content;
break;
case 'sick_note':
// Route to sick note field
document.getElementById('sick-note-textarea').value = content;
break;
// ... handle other task types
}
}
});
NoraRecorder's UI components vary based on the task type:
- Microphone Button: Primary button to start/stop recording
- Microphone Selection: Button to choose input device
- Language Selection: Button to select transcription language
- Template Selection: Button to choose note template
- Mode Toggle: Switch between consultation and dictation modes
- Microphone Button: Primary button to start/stop recording
- Microphone Selection: Button to choose input device
- Language Selection: Button to select transcription language
- Task Type Display: Shows "Recording for [Task Type]" text
When recording is active, additional controls appear:
- Waveform visualizer
- Timer
- Pause/Resume button
- Stop button
- Discard button
When processing is complete, a completion interface appears:
- Green checkmark indicating success
- Delete button to remove recording and restart
- Task-type aware delete functionality
The recorder can be in multiple states:
- CREATED: Initial state, ready to record
- RECORDING: Actively recording audio
- PAUSED: Recording temporarily paused
- STOPPING: Recording is ending
- UPLOADING: Audio is being uploaded
- STOPPED: Upload complete, processing on server
- COMPLETED: Processing completed successfully
- ERROR: An error occurred
- REMOVING: Deleting a recording
The recording process follows this workflow:
-
Initialization
- Component loads and initializes
- Authenticates with API
- Detects available microphones
-
Pre-Recording Setup
- User selects microphone (optional)
- User selects language/template (optional)
- User selects mode (consultation/dictation)
-
Recording
- User clicks microphone to start
- Audio visualization shows input levels
- Timer tracks duration
- User can pause/resume
- User can stop or discard
-
Processing
- Audio is uploaded with progress indicator
- Server processes the audio
- Status updates show processing stages
-
Completion
- Success message displayed
- Content is available in the EHR system
- Component returns to ready state
The NoraRecorder component implements several security measures:
-
Microphone Permissions
- Requests permissions only when needed
- Gives clear feedback when permissions are denied
-
Data Transmission
- Uses HTTPS for all API communication
- Transmits data using presigned URLs for secure upload
-
Authentication
- Requires API key for all operations
- Validates doctor ID with the server
-
Resource Management
- Automatically cleans up audio resources after use
- Destroys recorder instances when no longer needed
-
Content Security Policy (CSP)
- Compatible with strict CSP with proper configuration
For EHR platforms with CSP, ensure you:
- Add unpkg.com to your
script-src
directive - Add Nora API endpoints to your
connect-src
directive
Issue: Script fails to load or NoraRecorder is not defined.
Solution:
- Ensure you're using the correct URL for the script
- Check network tab for any loading errors
- Try specifying a specific version instead of 'latest'
- Use the bundled version (
nora-recorder-easy.js
)
Issue: NoraRecorder initializes but fails during operation.
Solution:
- Ensure all required parameters are correctly provided
- Check that API key and endpoints are correct
- Use the promise's catch handler to debug specific errors
Issue: Unable to access microphone.
Solution:
- Ensure user has granted microphone permissions
- Check browser security settings
- Verify HTTPS is being used (required for microphone access)
Issue: Recording not working correctly across tabs, or recorder being destroyed when switching tabs.
Solution:
- Use single recorder instance pattern - Don't create multiple simultaneous recorder instances
- Reinitialize on tab switch - Clean up and recreate the recorder when switching consultations
- Verify unique consultation IDs for different consultations/patients
-
Use proper cleanup sequence - Always call
cleanup()
before creating a new instance
Correct Pattern:
// ✅ CORRECT: Single instance with reinitialization
let currentRecorder = null;
function switchToConsultation(consultationID) {
// Clean up existing recorder
if (currentRecorder) {
currentRecorder.cleanup();
currentRecorder = null;
}
// Initialize new recorder
NoraRecorder({
consultationID: consultationID,
// ... other config
}).then(recorder => {
currentRecorder = recorder;
});
}
Avoid:
// ❌ INCORRECT: Multiple simultaneous instances
const recorders = {};
recorders.tab1 = NoraRecorder({consultationID: 'CONSULT-001'});
recorders.tab2 = NoraRecorder({consultationID: 'CONSULT-002'});
To enable detailed logging:
// Enable debug mode before initializing
window.NORA_RECORDER_DEBUG = true;
// Initialize as normal
NoraRecorder.ready(function() {
// ... normal initialization
});
Multiple recorder instances are supported and recommended for multi-tab scenarios where recordings need to continue in the background while users switch tabs. For multi-tab EHR systems, see Pattern 2 in the Multi-Tab Environment Handling section above.
Recommended for: Tab-based EHR systems where users switch between consultations during recordings Not recommended for: Simple page-based navigation where recordings always complete before navigation
Multiple simultaneous instances should only be used when you have completely separate workflows that need to operate independently (e.g., different modules of an application that don't interact with each other).
Limited Use Case Example (separate application modules):
NoraRecorder.ready(function() {
// Separate module 1: Emergency Department
NoraRecorder({
consultationID: 'emergency-001',
doctorID: 'doctor-123',
apiKey: 'YOUR_API_KEY',
apiBaseUrl: 'YOUR_API_ENDPOINT',
container: document.getElementById('emergency-recorder')
}).then(recorder1 => {
window.emergencyRecorder = recorder1;
});
// Separate module 2: Outpatient Clinic
NoraRecorder({
consultationID: 'outpatient-001',
doctorID: 'doctor-123',
apiKey: 'YOUR_API_KEY',
apiBaseUrl: 'YOUR_API_ENDPOINT',
container: document.getElementById('outpatient-recorder')
}).then(recorder2 => {
window.outpatientRecorder = recorder2;
});
});
Important Limitations & Safety Features:
- Only one recorder can be actively recording at any time (automatically enforced)
- System prevents multiple recordings and shows warning messages
- Each instance consumes resources and adds complexity
- Proper cleanup is critical for each instance
- Built-in recording coordination ensures data integrity
When working with strict CSP rules, ensure you:
- Add unpkg.com to your
script-src
directive - Allow inline scripts or use a nonce for the initialization code
- Add any API endpoints to your
connect-src
directive
Example CSP header:
Content-Security-Policy: script-src 'self' https://unpkg.com; connect-src 'self' https://your-api-endpoint.com;
For applications running in multiple environments (development, staging, production), create a configuration service:
// recorder-config.js
const environments = {
development: {
apiKey: 'DEV_API_KEY',
apiBaseUrl: 'https://dev-api.nora.ai/v1'
},
staging: {
apiKey: 'STAGING_API_KEY',
apiBaseUrl: 'https://staging-api.nora.ai/v1'
},
production: {
apiKey: 'PROD_API_KEY',
apiBaseUrl: 'https://api.nora.ai/v1'
}
};
// Determine current environment
const currentEnv = window.location.hostname.includes('dev') ? 'development' :
window.location.hostname.includes('staging') ? 'staging' :
'production';
// Export configuration
export const recorderConfig = {
...environments[currentEnv],
// Include functions to generate consultation IDs, etc.
generateConsultationID: function(patientId, visitDate) {
return `${patientId}-${visitDate.toISOString().split('T')[0]}`;
}
};
-
Initialize at the Right Time
- Initialize the recorder when a consultation view is loaded
- Don't initialize multiple recorders for the same consultation
-
Always Clean Up
- Call cleanup when navigating away from consultation views
- Ensure proper cleanup in SPAs during route changes
-
Handle Multi-Tab Scenarios
- Update UI to show which tab has an active recording
- Consider guiding users back to the recording tab
-
Proper Container Sizing for Embedded Mode
- Ensure container has minimum dimensions (290px width × 48px height)
- Check that container is visible in the viewport
-
Error Handling
- Implement proper error handling for initialization
- Provide user feedback for permissions issues
-
Performance
- Initialize recorders only when needed
- Clean up unused instances to free resources
-
Consistent Configuration
- Keep API key and API base URL consistent across the application
- Use a configuration service/provider to manage parameters
<!DOCTYPE html>
<html>
<head>
<title>EHR with NoraRecorder - Floating Mode</title>
</head>
<body>
<h1>Patient Consultation</h1>
<!-- EHR application content -->
<div id="patient-info">
<h2>Patient: John Doe</h2>
<p>DOB: 01/15/1980</p>
</div>
<script src="https://unpkg.com/@nora-technology/recorder@1.0.29/dist/nora-recorder-easy.js"></script>
<script>
// Initialize when page loads
document.addEventListener('DOMContentLoaded', function() {
NoraRecorder.ready(function() {
NoraRecorder({
apiKey: "YOUR_API_KEY",
doctorID: "DOCTOR_ID",
consultationID: "CONSULTATION_ID",
apiBaseUrl: "YOUR_API_ENDPOINT",
position: { x: 20, y: 20 }
}).then(recorder => {
console.log('Recorder initialized in floating mode');
window.currentRecorder = recorder;
});
});
});
// Clean up when page unloads
window.addEventListener('beforeunload', function() {
if (window.currentRecorder) {
NoraRecorder.cleanup();
}
});
</script>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<title>EHR with NoraRecorder - Embedded Mode</title>
<style>
.recorder-container {
display: inline-block;
min-height: 48px;
min-width: 290px;
vertical-align: middle;
margin: 10px 0;
}
</style>
</head>
<body>
<h1>Patient Consultation</h1>
<!-- EHR application content -->
<div id="notes-section">
<h2>Clinical Notes</h2>
<div>Click the recorder to capture your notes:</div>
<div id="recorder-container" class="recorder-container"></div>
<textarea placeholder="Notes will appear here..." rows="10" cols="60"></textarea>
</div>
<script src="https://unpkg.com/@nora-technology/recorder@1.0.29/dist/nora-recorder-easy.js"></script>
<script>
// Initialize when page loads
document.addEventListener('DOMContentLoaded', function() {
NoraRecorder.ready(function() {
NoraRecorder({
apiKey: "YOUR_API_KEY",
doctorID: "DOCTOR_ID",
consultationID: "CONSULTATION_ID",
apiBaseUrl: "YOUR_API_ENDPOINT",
container: document.getElementById('recorder-container'),
position: null,
snackbarPosition: "below-component"
}).then(recorder => {
console.log('Recorder initialized in embedded mode');
window.currentRecorder = recorder;
});
});
});
</script>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<title>Multi-Tab EHR Implementation - Corrected</title>
<style>
/* Styles for tabs and recording indicators */
.tabs {
display: flex;
margin-bottom: 20px;
}
.tab {
padding: 10px 15px;
margin-right: 5px;
background: #f0f0f0;
cursor: pointer;
border: 1px solid #ccc;
position: relative;
}
.tab.active {
background: #4f46e5;
color: white;
}
.recording-indicator {
display: none;
width: 10px;
height: 10px;
background-color: #dc2626;
border-radius: 50%;
position: absolute;
top: 5px;
right: 5px;
}
.consultation {
display: none;
padding: 20px;
border: 1px solid #ccc;
}
.consultation.active {
display: block;
}
.recorder-container {
display: inline-block;
min-height: 48px;
min-width: 290px;
vertical-align: middle;
margin: 10px 0;
}
</style>
</head>
<body>
<h1>EHR Multi-Tab Simulation (Corrected)</h1>
<div class="tabs">
<div class="tab active" data-consultation-id="CONSULT-001">
Patient 1 - John Doe
<span class="recording-indicator"></span>
</div>
<div class="tab" data-consultation-id="CONSULT-002">
Patient 2 - Jane Smith
<span class="recording-indicator"></span>
</div>
<div class="tab" data-consultation-id="CONSULT-003">
Patient 3 - Robert Johnson
<span class="recording-indicator"></span>
</div>
</div>
<!-- Single recorder container - reused for all consultations -->
<div id="current-consultation">
<h2 id="patient-name">Patient: John Doe</h2>
<div class="recorder-container" id="recorder-container"></div>
<textarea id="notes-textarea" placeholder="Notes will appear here..."></textarea>
<div id="consultation-id" style="font-size: 12px; color: #666; margin-top: 10px;">
Consultation ID: <span id="current-consultation-id">CONSULT-001</span>
</div>
</div>
<script src="https://unpkg.com/@nora-technology/recorder@1.0.29/dist/nora-recorder-easy.js"></script>
<script>
// Configuration constants
const API_KEY = "YOUR_API_KEY";
const DOCTOR_ID = "DOCTOR_ID";
const API_BASE_URL = "YOUR_API_ENDPOINT";
// ✅ CORRECT: Single recorder instance that gets reinitialized
let currentRecorder = null;
let currentConsultationID = null;
// Patient data
const patients = {
"CONSULT-001": { name: "John Doe", notes: "" },
"CONSULT-002": { name: "Jane Smith", notes: "" },
"CONSULT-003": { name: "Robert Johnson", notes: "" }
};
// Switch to a consultation
function switchToConsultation(consultationID) {
// Skip if already on this consultation
if (currentConsultationID === consultationID && currentRecorder) {
console.log(`Already on consultation ${consultationID}`);
return;
}
console.log(`Switching to consultation ${consultationID}`);
// Save current notes before switching
if (currentConsultationID && patients[currentConsultationID]) {
patients[currentConsultationID].notes = document.getElementById('notes-textarea').value;
}
// Clean up existing recorder
if (currentRecorder) {
currentRecorder.cleanup();
currentRecorder = null;
}
// Clear container
const container = document.getElementById('recorder-container');
container.innerHTML = '';
// Update UI for new consultation
const patient = patients[consultationID];
document.getElementById('patient-name').textContent = `Patient: ${patient.name}`;
document.getElementById('current-consultation-id').textContent = consultationID;
document.getElementById('notes-textarea').value = patient.notes;
// Initialize new recorder for this consultation
NoraRecorder.ready(function() {
NoraRecorder({
apiKey: API_KEY,
doctorID: DOCTOR_ID,
consultationID: consultationID,
apiBaseUrl: API_BASE_URL,
container: container,
position: null,
snackbarPosition: "below-component"
}).then(recorder => {
currentRecorder = recorder;
currentConsultationID = consultationID;
console.log(`✅ Recorder initialized for ${consultationID}`);
updateRecordingIndicators();
}).catch(error => {
console.error(`❌ Error initializing recorder for ${consultationID}:`, error);
});
});
}
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
// Update tab UI
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Switch to this consultation
const consultationID = tab.getAttribute('data-consultation-id');
switchToConsultation(consultationID);
});
});
// Update recording indicators
function updateRecordingIndicators() {
const activeRecordingConsultationID = NoraRecorder.isRecordingInProgress()
? NoraRecorder.getActiveConsultationID()
: null;
document.querySelectorAll('.tab').forEach(tab => {
const tabConsultationID = tab.getAttribute('data-consultation-id');
const indicator = tab.querySelector('.recording-indicator');
if (activeRecordingConsultationID === tabConsultationID) {
indicator.style.display = 'block';
} else {
indicator.style.display = 'none';
}
});
}
// Listen for recording status changes
document.addEventListener('nora-recorder-status', (event) => {
console.log(`Recording event: ${event.detail.type} for ${event.detail.consultationID}`);
updateRecordingIndicators();
});
// Listen for content events
document.addEventListener('nora-recorder-content', (event) => {
const { type, consultationID, content } = event.detail;
if (type === 'content-ready' && consultationID === currentConsultationID) {
console.log(`Content generated for ${consultationID}: ${content.length} characters`);
// Update textarea with generated content
document.getElementById('notes-textarea').value = content;
// Store in patient data
if (patients[consultationID]) {
patients[consultationID].notes = content;
}
}
});
// Save notes when typing
document.getElementById('notes-textarea').addEventListener('input', (e) => {
if (currentConsultationID && patients[currentConsultationID]) {
patients[currentConsultationID].notes = e.target.value;
}
});
// Initialize with first consultation
document.addEventListener('DOMContentLoaded', () => {
switchToConsultation('CONSULT-001');
});
// Cleanup function for page unload
window.addEventListener('beforeunload', function() {
if (currentRecorder) {
currentRecorder.cleanup();
}
});
// Debug helpers
window.debugHelpers = {
getCurrentConsultation: () => currentConsultationID,
getCurrentRecorder: () => currentRecorder,
getPatientData: () => patients,
isRecording: () => NoraRecorder.isRecordingInProgress(),
getActiveRecording: () => NoraRecorder.getActiveConsultationID()
};
console.log('🏥 Multi-Tab EHR Demo Ready');
console.log('✅ Using single recorder instance with reinitialization pattern');
console.log('🔧 Available debug helpers:', Object.keys(window.debugHelpers));
</script>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<title>EHR with Auto-Content Integration</title>
<style>
.consultation-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.recorder-section {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.notes-section {
background: #ffffff;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
}
.recorder-container {
display: inline-block;
min-height: 48px;
min-width: 290px;
margin: 10px 0;
}
.notes-textarea {
width: 100%;
min-height: 300px;
border: 1px solid #ccc;
border-radius: 4px;
padding: 10px;
font-family: 'Segoe UI', sans-serif;
font-size: 14px;
line-height: 1.5;
}
.status-indicator {
padding: 10px;
border-radius: 4px;
margin: 10px 0;
display: none;
}
.status-processing {
background: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
}
.status-success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.timestamp {
font-size: 12px;
color: #666;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="consultation-container">
<h1>Patient Consultation - Auto Content Integration</h1>
<div class="recorder-section">
<h3>Voice Recording</h3>
<p>Click the recorder to capture your consultation notes:</p>
<div class="recorder-container" id="recorder-container"></div>
</div>
<div class="status-indicator" id="processing-status">
Processing your recording...
</div>
<div class="status-indicator" id="success-status">
✓ Clinical notes generated successfully and saved automatically!
</div>
<div class="notes-section">
<h3>Clinical Notes</h3>
<textarea
id="notes-textarea"
class="notes-textarea"
placeholder="Clinical notes will appear here automatically when your recording is processed..."
data-consultation-id="CONSULT-123"
></textarea>
<div class="timestamp" id="notes-timestamp"></div>
</div>
</div>
<script src="https://unpkg.com/@nora-technology/recorder@1.0.29/dist/nora-recorder-easy.js"></script>
<script>
// Configuration
const CONSULTATION_ID = "CONSULT-123";
const API_KEY = "YOUR_API_KEY";
const DOCTOR_ID = "DOCTOR_ID";
const API_BASE_URL = "YOUR_API_ENDPOINT";
// Get DOM elements
const notesTextarea = document.getElementById('notes-textarea');
const processingStatus = document.getElementById('processing-status');
const successStatus = document.getElementById('success-status');
const timestampDiv = document.getElementById('notes-timestamp');
// Content event listener - handles generated content
document.addEventListener('nora-recorder-content', async (event) => {
const { type, consultationID, content, timestamp } = event.detail;
// Only handle events for this consultation
if (consultationID === CONSULTATION_ID && type === 'content-ready') {
console.log('Received generated content:', content.substring(0, 100) + '...');
// Hide processing indicator
processingStatus.style.display = 'none';
// Insert the generated content into the textarea
notesTextarea.value = content;
// Show success indicator
successStatus.style.display = 'block';
// Update timestamp
const date = new Date(timestamp);
timestampDiv.textContent = `Generated: ${date.toLocaleString()}`;
// Trigger any change events your EHR might need
notesTextarea.dispatchEvent(new Event('input', { bubbles: true }));
// Optional: Auto-save to your backend
try {
await saveNotesToBackend(consultationID, content);
console.log('Notes saved to backend successfully');
} catch (error) {
console.error('Error saving notes to backend:', error);
}
// Auto-hide success message after 5 seconds
setTimeout(() => {
successStatus.style.display = 'none';
}, 5000);
}
});
// Status event listener - handles recording status changes
document.addEventListener('nora-recorder-status', (event) => {
const { type, consultationID } = event.detail;
if (consultationID === CONSULTATION_ID) {
if (type === 'recording-stopped') {
// Show processing indicator when recording stops
processingStatus.style.display = 'block';
successStatus.style.display = 'none';
} else if (type === 'recording-discarded') {
// Hide processing indicator if recording is discarded
processingStatus.style.display = 'none';
successStatus.style.display = 'none';
}
}
});
// Function to save notes to your backend
async function saveNotesToBackend(consultationId, notes) {
const response = await fetch('/api/consultations/notes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}` // If your backend needs auth
},
body: JSON.stringify({
consultationId: consultationId,
notes: notes,
timestamp: new Date().toISOString(),
source: 'nora-recorder'
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// Initialize NoraRecorder
NoraRecorder.ready(function() {
NoraRecorder({
apiKey: API_KEY,
doctorID: DOCTOR_ID,
consultationID: CONSULTATION_ID,
apiBaseUrl: API_BASE_URL,
container: document.getElementById('recorder-container'),
position: null,
snackbarPosition: "below-component"
}).then(recorder => {
console.log('Recorder initialized successfully');
// Check if content is already available (for existing recordings)
if (recorder.isContentReady()) {
const existingContent = recorder.getGeneratedContent();
console.log('Found existing content:', existingContent.substring(0, 100) + '...');
// Insert existing content
notesTextarea.value = existingContent;
timestampDiv.textContent = 'Previously generated content loaded';
}
}).catch(error => {
console.error('Error initializing recorder:', error);
});
});
// Cleanup when page unloads
window.addEventListener('beforeunload', function() {
NoraRecorder.cleanup();
});
</script>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<title>EHR with Task Type System</title>
<style>
.consultation-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.task-selector {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.task-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.task-button {
padding: 10px 20px;
border: 2px solid #ddd;
background: white;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s ease;
}
.task-button.active {
background: #4f46e5;
color: white;
border-color: #4f46e5;
}
.documentation-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 20px;
margin-top: 20px;
}
.doc-section {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
}
.doc-section h3 {
margin-top: 0;
color: #4f46e5;
border-bottom: 2px solid #4f46e5;
padding-bottom: 5px;
}
.recorder-container {
display: inline-block;
min-height: 48px;
min-width: 290px;
margin: 10px 0;
}
.content-area {
width: 100%;
min-height: 150px;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
font-family: monospace;
font-size: 14px;
}
.completion-indicator {
color: #10B981;
font-weight: bold;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="consultation-container">
<h1>Patient Consultation - Task Type System</h1>
<div class="task-selector">
<h2>Select Documentation Type</h2>
<p>Choose the type of documentation you want to create. Each type has its own recorder and content area.</p>
<div class="task-buttons">
<button class="task-button active" data-task="clinical_notes">Clinical Notes</button>
<button class="task-button" data-task="referral_letter">Referral Letter</button>
<button class="task-button" data-task="sick_note">Sick Note</button>
<button class="task-button" data-task="patient_notes">Patient Notes</button>
<button class="task-button" data-task="vitals">Vitals</button>
</div>
</div>
<div class="documentation-grid">
<div class="doc-section">
<h3>Clinical Notes <span id="clinical-notes-status" class="completion-indicator" style="display: none;">✓</span></h3>
<p><strong>Features:</strong> Template selection, mode toggle, full interface</p>
<p><strong>Terminal Status:</strong> 'sent'</p>
<div id="recorder-clinical-notes" class="recorder-container"></div>
<textarea id="content-clinical-notes" class="content-area" placeholder="Clinical notes will appear here..."></textarea>
</div>
<div class="doc-section">
<h3>Referral Letter <span id="referral-letter-status" class="completion-indicator" style="display: none;">✓</span></h3>
<p><strong>Features:</strong> Simplified interface, task type display</p>
<p><strong>Terminal Status:</strong> 'generated'</p>
<div id="recorder-referral-letter" class="recorder-container"></div>
<textarea id="content-referral-letter" class="content-area" placeholder="Referral letter content will appear here..."></textarea>
</div>
<div class="doc-section">
<h3>Sick Note <span id="sick-note-status" class="completion-indicator" style="display: none;">✓</span></h3>
<p><strong>Features:</strong> Simplified interface, task type display</p>
<p><strong>Terminal Status:</strong> 'generated'</p>
<div id="recorder-sick-note" class="recorder-container"></div>
<textarea id="content-sick-note" class="content-area" placeholder="Sick note content will appear here..."></textarea>
</div>
<div class="doc-section">
<h3>Patient Notes <span id="patient-notes-status" class="completion-indicator" style="display: none;">✓</span></h3>
<p><strong>Features:</strong> Simplified interface, task type display</p>
<p><strong>Terminal Status:</strong> 'generated'</p>
<div id="recorder-patient-notes" class="recorder-container"></div>
<textarea id="content-patient-notes" class="content-area" placeholder="Patient notes will appear here..."></textarea>
</div>
<div class="doc-section">
<h3>Vitals <span id="vitals-status" class="completion-indicator" style="display: none;">✓</span></h3>
<p><strong>Features:</strong> Simplified interface, task type display</p>
<p><strong>Terminal Status:</strong> 'generated'</p>
<div id="recorder-vitals" class="recorder-container"></div>
<textarea id="content-vitals" class="content-area" placeholder="Vitals information will appear here..."></textarea>
</div>
</div>
</div>
<script src="https://unpkg.com/@nora-technology/recorder@1.0.29/dist/nora-recorder-easy.js"></script>
<script>
// Configuration - same consultation ID for all task types (real EHR pattern)
const CONSULTATION_ID = "PATIENT-VISIT-123454";
const config = {
apiKey: "YOUR_API_KEY",
doctorID: "DOCTOR_ID",
consultationID: CONSULTATION_ID, // Same for all task types!
apiBaseUrl: "YOUR_API_ENDPOINT"
};
// Task types and their display names
const taskTypes = [
'clinical_notes',
'referral_letter',
'sick_note',
'patient_notes',
'vitals'
];
const taskDisplayNames = {
'clinical_notes': 'Clinical Notes',
'referral_letter': 'Referral Letter',
'sick_note': 'Sick Note',
'patient_notes': 'Patient Notes',
'vitals': 'Vitals'
};
// Store recorder instances
const recorders = {};
// Initialize all recorders
function initializeAllRecorders() {
NoraRecorder.ready(function() {
taskTypes.forEach(taskType => {
const container = document.getElementById(`recorder-${taskType.replace('_', '-')}`);
NoraRecorder({
...config,
taskType: taskType,
container: container,
position: null,
snackbarPosition: "below-component"
}).then(recorder => {
recorders[taskType] = recorder;
console.log(`Initialized ${taskType} recorder`);
}).catch(error => {
console.error(`Error initializing ${taskType} recorder:`, error);
});
});
});
}
// Handle task type selection (for highlighting active type)
document.querySelectorAll('.task-button').forEach(button => {
button.addEventListener('click', () => {
// Update button states
document.querySelectorAll('.task-button').forEach(b => b.classList.remove('active'));
button.classList.add('active');
const taskType = button.getAttribute('data-task');
console.log(`Selected task type: ${taskDisplayNames[taskType]}`);
});
});
// Listen for content events and route to appropriate text areas
document.addEventListener('nora-recorder-content', (event) => {
const { type, consultationID, content, taskType } = event.detail;
if (type === 'content-ready' && consultationID === CONSULTATION_ID) {
console.log(`Content generated for ${taskType}: ${content.length} characters`);
// Route content to appropriate text area
const contentArea = document.getElementById(`content-${taskType.replace('_', '-')}`);
if (contentArea) {
contentArea.value = content;
// Show completion indicator
const statusIndicator = document.getElementById(`${taskType.replace('_', '-')}-status`);
if (statusIndicator) {
statusIndicator.style.display = 'inline';
}
// In a real EHR, you would save this to your backend with:
// - consultationID: CONSULTATION_ID
// - taskType: taskType
// - content: content
// - timestamp: new Date().toISOString()
console.log(`${taskDisplayNames[taskType]} content saved for consultation ${CONSULTATION_ID}`);
}
}
});
// Listen for status events
document.addEventListener('nora-recorder-status', (event) => {
const { type, consultationID, taskType } = event.detail;
if (consultationID === CONSULTATION_ID) {
console.log(`Status event: ${type} for ${taskDisplayNames[taskType || 'unknown']}`);
}
});
// Initialize all recorders when page loads
initializeAllRecorders();
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
Object.values(recorders).forEach(recorder => {
if (recorder && recorder.cleanup) {
recorder.cleanup();
}
});
});
</script>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<title>EHR with Context Recommendations System</title>
<style>
.consultation-container {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
.recorder-section {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.recorder-container {
display: inline-block;
min-height: 48px;
min-width: 290px;
margin: 10px 0;
}
.notes-section {
background: #ffffff;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
margin-bottom: 20px;
}
.notes-textarea {
width: 100%;
min-height: 200px;
border: 1px solid #ccc;
border-radius: 4px;
padding: 10px;
font-family: 'Segoe UI', sans-serif;
font-size: 14px;
line-height: 1.5;
}
.context-info {
background: #e3f2fd;
border: 1px solid #bbdefb;
border-radius: 4px;
padding: 15px;
margin-bottom: 20px;
}
.context-info h3 {
margin-top: 0;
color: #1976d2;
}
.position-controls {
margin: 15px 0;
}
.position-button {
padding: 8px 16px;
margin-right: 10px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.position-button.active {
background: #4f46e5;
color: white;
border-color: #4f46e5;
}
.toggle-button {
padding: 10px 20px;
background: #dc2626;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin: 10px 0;
}
.toggle-button.enabled {
background: #10b981;
}
</style>
</head>
<body>
<div class="consultation-container">
<h1>Patient Consultation - Context Recommendations Demo</h1>
<div class="context-info">
<h3>🎯 Context Recommendations System</h3>
<p>This demo shows the context recommendations system in action:</p>
<ul>
<li><strong>Clinical Notes:</strong> Context recommendations appear after completion</li>
<li><strong>Other Task Types:</strong> Context recommendations appear if clinical notes exist</li>
<li><strong>Positioning:</strong> Try different positions for the context dropdown</li>
<li><strong>Toggle System:</strong> Enable/disable the entire context system</li>
</ul>
<div class="position-controls">
<strong>Context Position:</strong>
<button class="position-button active" data-position="below">Below</button>
<button class="position-button" data-position="right">Right</button>
<button class="position-button" data-position="left">Left</button>
</div>
<button id="toggle-context" class="toggle-button enabled">
Context System: ENABLED
</button>
</div>
<div class="recorder-section">
<h2>Voice Recorder</h2>
<p>Record clinical notes to see context recommendations appear automatically.</p>
<div id="recorder-container" class="recorder-container"></div>
</div>
<div class="notes-section">
<h2>Clinical Notes</h2>
<textarea
id="notes-textarea"
class="notes-textarea"
placeholder="Clinical notes will appear here automatically when your recording is processed..."
></textarea>
</div>
</div>
<script src="https://unpkg.com/@nora-technology/recorder@1.0.29/dist/nora-recorder-easy.js"></script>
<script>
// Configuration
const CONSULTATION_ID = "CONTEXT-DEMO-001";
const config = {
apiKey: "YOUR_API_KEY",
doctorID: "DOCTOR_ID",
consultationID: CONSULTATION_ID,
apiBaseUrl: "YOUR_API_ENDPOINT",
enableContextChecker: true,
contextPosition: "below"
};
let currentRecorder = null;
let currentContextEnabled = true;
let currentContextPosition = "below";
// Initialize recorder with current configuration
function initializeRecorder() {
// Clean up existing recorder
if (currentRecorder) {
currentRecorder.cleanup();
currentRecorder = null;
}
// Clear container
const container = document.getElementById('recorder-container');
container.innerHTML = '';
// Initialize new recorder with current settings
NoraRecorder.ready(function() {
NoraRecorder({
...config,
enableContextChecker: currentContextEnabled,
contextPosition: currentContextPosition,
container: container,
position: null,
snackbarPosition: "below-component"
}).then(recorder => {
currentRecorder = recorder;
console.log(`Recorder initialized with context ${currentContextEnabled ? 'enabled' : 'disabled'} at position ${currentContextPosition}`);
}).catch(error => {
console.error('Error initializing recorder:', error);
});
});
}
// Handle position changes
document.querySelectorAll('.position-button').forEach(button => {
button.addEventListener('click', () => {
// Update button states
document.querySelectorAll('.position-button').forEach(b => b.classList.remove('active'));
button.classList.add('active');
// Update position and reinitialize
currentContextPosition = button.getAttribute('data-position');
console.log(`Switching context position to: ${currentContextPosition}`);
initializeRecorder();
});
});
// Handle context system toggle
document.getElementById('toggle-context').addEventListener('click', (e) => {
currentContextEnabled = !currentContextEnabled;
// Update button appearance
const button = e.target;
if (currentContextEnabled) {
button.textContent = 'Context System: ENABLED';
button.classList.add('enabled');
button.style.backgroundColor = '#10b981';
} else {
button.textContent = 'Context System: DISABLED';
button.classList.remove('enabled');
button.style.backgroundColor = '#dc2626';
}
console.log(`Context system ${currentContextEnabled ? 'enabled' : 'disabled'}`);
initializeRecorder();
});
// Listen for content events
document.addEventListener('nora-recorder-content', (event) => {
const { type, consultationID, content } = event.detail;
if (type === 'content-ready' && consultationID === CONSULTATION_ID) {
console.log('Content generated:', content.substring(0, 100) + '...');
// Insert content into textarea
document.getElementById('notes-textarea').value = content;
// Log context system status
if (currentContextEnabled) {
console.log('✅ Context recommendations should appear below the recorder');
} else {
console.log('🚫 Context system disabled - no recommendations will appear');
}
}
});
// Listen for status events
document.addEventListener('nora-recorder-status', (event) => {
const { type, consultationID } = event.detail;
if (consultationID === CONSULTATION_ID) {
console.log(`Status event: ${type}`);
if (type === 'recording-stopped' && currentContextEnabled) {
console.log('🔄 Recording stopped - context check will be triggered after processing completes');
}
}
});
// Monitor for context checker appearance
function monitorContextChecker() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE &&
node.classList &&
node.classList.contains('context-checker-container')) {
console.log('🎯 Context checker component appeared!');
console.log(`Position: ${currentContextPosition}`);
console.log('Click the header to expand/collapse recommendations');
}
});
});
});
// Observe the document for context checker additions
observer.observe(document.body, {
childList: true,
subtree: true
});
return observer;
}
// Start monitoring
const contextObserver = monitorContextChecker();
// Initialize recorder
initializeRecorder();
// Add debugging helpers
window.contextDemoHelpers = {
// Check if context checker is visible
isContextVisible: () => {
const contextChecker = document.querySelector('.context-checker-container');
return contextChecker && contextChecker.style.display !== 'none';
},
// Get current configuration
getCurrentConfig: () => ({
enabled: currentContextEnabled,
position: currentContextPosition,
consultationId: CONSULTATION_ID
}),
// Simulate context recommendations (for testing)
simulateContext: () => {
console.log('This would trigger context recommendations in a real environment');
console.log('Expected recommendations based on clinical content:');
console.log('- Referral Letter: Recommended');
console.log('- Blood Test: Recommended');
console.log('- Follow-up Consultation: Recommended');
}
};
// Log demo information
console.log('🎯 Context Recommendations Demo Ready');
console.log('📋 Instructions:');
console.log(' 1. Record clinical notes to see context recommendations');
console.log(' 2. Try different positions (below/right/left)');
console.log(' 3. Toggle the context system on/off');
console.log(' 4. Check console for context-related events');
console.log('📝 Available helpers:', Object.keys(window.contextDemoHelpers));
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
if (currentRecorder) {
currentRecorder.cleanup();
}
if (contextObserver) {
contextObserver.disconnect();
}
});
</script>
</body>
</html>
This integration guide provides comprehensive information for integrating the NoraRecorder component into any EHR platform. The context recommendations system enhances clinical workflows by providing intelligent suggestions for additional documentation, while the task type system allows for flexible documentation workflows with consistent API behavior and user experience.
For additional support or questions, please contact your Nora representative or email info@nora-ai.com