Skip to main content
Sully.ai generates clinical notes with structured JSON output, enabling direct integration with Electronic Health Record (EHR) systems. This guide covers how to extract, transform, and map JSON data to your EHR fields.

Overview

When generating clinical notes, Sully.ai provides structured JSON alongside markdown output. This structured data makes it straightforward to:
  • Map to EHR fields - Extract specific data points for direct field population
  • Maintain consistency - Ensure predictable output structure for automated workflows
  • Validate data - Apply schemas and business rules before writing to EHR
  • Audit transformations - Track data flow from note generation to EHR entry

Structured Output Options

ApproachJSON StructureBest For
SOAP NotesFixed structure with subjective, objective, assessment, planStandard clinical documentation
Note TemplatesCustom structure matching your template sectionsEHR-specific field requirements
Text-to-JSONSchema-defined extraction from any textPost-processing existing notes

JSON Output from SOAP Notes

When using noteType: { type: 'soap' }, the response includes both markdown and JSON formats. The JSON provides structured access to each SOAP section.

SOAP JSON Structure

{
  "data": {
    "noteId": "note_abc123",
    "status": "completed",
    "payload": {
      "markdown": "## Subjective\nPatient reports...",
      "json": {
        "subjective": {
          "chiefComplaint": "Headaches for one week",
          "historyOfPresentIllness": "Patient reports persistent headaches starting 7 days ago. Pain is described as throbbing, localized to the right temple. Onset typically in the afternoon. Associated with photophobia and occasional nausea. No aura reported.",
          "reviewOfSystems": {
            "constitutional": "Denies fever, chills, weight changes",
            "neurological": "Positive for headache, photophobia. Negative for vision changes, weakness, numbness"
          }
        },
        "objective": {
          "vitalSigns": {
            "bloodPressure": "120/80",
            "heartRate": "72",
            "temperature": "98.6F"
          },
          "physicalExam": {
            "general": "Alert and oriented, no acute distress",
            "neurological": "Cranial nerves II-XII intact, no focal deficits"
          }
        },
        "assessment": {
          "diagnoses": [
            {
              "description": "Migraine without aura",
              "icdCode": "G43.909"
            }
          ],
          "clinicalImpression": "Presentation consistent with episodic migraine headaches based on unilateral throbbing pain with photophobia."
        },
        "plan": {
          "medications": [
            {
              "name": "Sumatriptan",
              "dosage": "50mg",
              "frequency": "As needed for acute episodes",
              "instructions": "Take at onset of headache, may repeat once after 2 hours if needed"
            }
          ],
          "patientEducation": [
            "Maintain headache diary to identify triggers",
            "Avoid known triggers including bright lights and stress"
          ],
          "followUp": "Return in 4 weeks or sooner if symptoms worsen"
        }
      }
    }
  }
}

Accessing SOAP JSON Fields

import SullyAI from '@sullyai/sullyai';

const client = new SullyAI();

// Generate and retrieve SOAP note
const note = await client.notes.create({
  transcript: "Doctor: What brings you in today?...",
  noteType: { type: 'soap' },
});

// Poll until complete
let result = await client.notes.retrieve(note.noteId);
while (result.status === 'processing') {
  await new Promise((resolve) => setTimeout(resolve, 2000));
  result = await client.notes.retrieve(note.noteId);
}

// Access structured JSON fields
const { json } = result.payload;

// Extract specific fields for EHR
const chiefComplaint = json.subjective?.chiefComplaint;
const diagnoses = json.assessment?.diagnoses || [];
const medications = json.plan?.medications || [];

console.log('Chief Complaint:', chiefComplaint);
console.log('Diagnoses:', diagnoses.map(d => d.description));
console.log('Medications:', medications.map(m => `${m.name} ${m.dosage}`));

JSON Output from Templates

Note templates return structured JSON matching your template section definitions. Each section includes a result field containing the generated content.

Template JSON Structure

When you create a note with a template, the response includes the template structure with populated result fields:
{
  "data": {
    "noteId": "note_xyz789",
    "status": "completed",
    "payload": {
      "markdown": "# Chief Complaint\nHeadaches for one week...",
      "json": {
        "sections": [
          {
            "id": "cc-heading",
            "type": "heading",
            "properties": { "level": 1, "text": "Chief Complaint" },
            "children": [
              {
                "id": "cc-content",
                "type": "text",
                "prompt": "State the primary reason for the visit",
                "result": "Headaches for one week"
              }
            ]
          },
          {
            "id": "hpi-heading",
            "type": "heading",
            "properties": { "level": 1, "text": "History of Present Illness" },
            "children": [
              {
                "id": "hpi-content",
                "type": "text",
                "prompt": "Describe symptom history",
                "result": "Patient reports persistent throbbing headaches beginning 7 days ago. Pain localizes to the right temporal region with intensity rated 7/10. Episodes typically occur in afternoon hours and last 2-4 hours. Associated symptoms include photophobia and mild nausea."
              }
            ]
          },
          {
            "id": "plan-heading",
            "type": "heading",
            "properties": { "level": 1, "text": "Plan" },
            "children": [
              {
                "id": "plan-items",
                "type": "list",
                "prompt": "List treatment plan items",
                "result": [
                  "Start sumatriptan 50mg PRN for acute episodes",
                  "Maintain headache diary for trigger identification",
                  "Return in 4 weeks for follow-up"
                ]
              }
            ]
          }
        ]
      }
    }
  }
}

Extracting Template Results

import SullyAI from '@sullyai/sullyai';

const client = new SullyAI();

// Define template with predictable section IDs
const template = {
  id: 'ehr-integration-template',
  title: 'EHR Integration Template',
  sections: [
    {
      id: 'chief-complaint',
      type: 'heading',
      properties: { level: 1, text: 'Chief Complaint' },
      children: [
        {
          id: 'cc-content',
          type: 'text',
          prompt: 'State the primary reason for visit in one sentence',
          properties: { max_sentences: 1 },
        },
      ],
    },
    {
      id: 'diagnoses',
      type: 'heading',
      properties: { level: 1, text: 'Diagnoses' },
      children: [
        {
          id: 'diagnosis-list',
          type: 'list',
          prompt: 'List all diagnoses discussed',
          properties: { list_type: 'numeric' },
        },
      ],
    },
  ],
};

const note = await client.notes.create({
  transcript: "...",
  noteType: { type: 'note_template', template },
});

// Helper function to find section by ID
function findSection(sections: any[], id: string): any {
  for (const section of sections) {
    if (section.id === id) return section;
    if (section.children) {
      const found = findSection(section.children, id);
      if (found) return found;
    }
  }
  return null;
}

// Retrieve and extract results by section ID
const result = await client.notes.retrieve(note.noteId);
const sections = result.payload.json.sections;

const chiefComplaint = findSection(sections, 'cc-content')?.result;
const diagnoses = findSection(sections, 'diagnosis-list')?.result || [];

console.log('Chief Complaint:', chiefComplaint);
console.log('Diagnoses:', diagnoses);
Use meaningful, consistent section IDs in your templates. This makes it easy to extract specific fields programmatically without parsing the entire structure.

Text-to-JSON Utility

The text-to-JSON endpoint transforms unstructured text into structured JSON using a schema you define. This is useful for extracting specific fields from existing notes or normalizing data.

Endpoint

POST /v1/utils/text-to-json

Use Cases

  • Extract specific fields from free-text clinical notes
  • Normalize data from different note formats
  • Pull structured data from legacy documentation
  • Create custom field extractions not covered by SOAP or templates

Example: Extract Medications from Free Text

// Define a schema for the data you want to extract
const schema = {
  type: 'object',
  properties: {
    medications: {
      type: 'array',
      items: {
        type: 'object',
        properties: {
          name: { type: 'string', description: 'Medication name' },
          dosage: { type: 'string', description: 'Dosage amount and unit' },
          frequency: { type: 'string', description: 'How often to take' },
          route: { type: 'string', description: 'Route of administration' },
        },
        required: ['name'],
      },
    },
    allergies: {
      type: 'array',
      items: { type: 'string' },
      description: 'List of medication allergies',
    },
  },
};

const response = await fetch('https://api.sully.ai/v1/utils/text-to-json', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.SULLY_API_KEY!,
    'X-Account-Id': process.env.SULLY_ACCOUNT_ID!,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    text: `Patient is currently taking lisinopril 10mg once daily for blood pressure,
           metformin 500mg twice daily with meals for diabetes, and aspirin 81mg daily.
           Patient reports allergy to penicillin (rash) and sulfa drugs.`,
    schema: schema,
  }),
});

const result = await response.json();
console.log(JSON.stringify(result.data, null, 2));

// Output:
// {
//   "medications": [
//     { "name": "lisinopril", "dosage": "10mg", "frequency": "once daily", "route": "oral" },
//     { "name": "metformin", "dosage": "500mg", "frequency": "twice daily with meals", "route": "oral" },
//     { "name": "aspirin", "dosage": "81mg", "frequency": "daily", "route": "oral" }
//   ],
//   "allergies": ["penicillin", "sulfa drugs"]
// }

Field Mapping Patterns

Map Sully.ai JSON output to your EHR system fields. The following table shows common mappings.

SOAP JSON to EHR Field Mappings

SOAP JSON PathEHR FieldData TypeNotes
subjective.chiefComplaintChiefComplaintStringPrimary reason for visit
subjective.historyOfPresentIllnessHPIStringSymptom narrative
subjective.reviewOfSystems.*ROS_*ObjectMap each system separately
objective.vitalSigns.bloodPressureVitalSigns.BPStringFormat: “120/80”
objective.vitalSigns.heartRateVitalSigns.HRStringBeats per minute
objective.physicalExam.*PhysicalExam_*ObjectMap each exam section
assessment.diagnoses[]ProblemListArrayEach diagnosis as separate entry
assessment.diagnoses[].icdCodeDiagnosisCodeStringICD-10 code if available
assessment.clinicalImpressionClinicalImpressionStringAssessment narrative
plan.medications[]MedicationOrdersArrayEach medication as order
plan.patientEducation[]PatientInstructionsArrayEducation items
plan.followUpFollowUpInstructionsStringReturn visit info

Mapping Implementation

interface EHRRecord {
  chiefComplaint: string;
  hpiNarrative: string;
  vitalSigns: {
    bloodPressure: string;
    heartRate: string;
    temperature: string;
  };
  problemList: Array<{
    description: string;
    code?: string;
    codeSystem?: string;
  }>;
  medicationOrders: Array<{
    drugName: string;
    dose: string;
    frequency: string;
    instructions?: string;
  }>;
  followUp: string;
}

function mapSoapToEHR(soapJson: any): EHRRecord {
  const { subjective, objective, assessment, plan } = soapJson;

  return {
    // Single-value fields
    chiefComplaint: subjective?.chiefComplaint || '',
    hpiNarrative: subjective?.historyOfPresentIllness || '',

    // Nested object mapping
    vitalSigns: {
      bloodPressure: objective?.vitalSigns?.bloodPressure || '',
      heartRate: objective?.vitalSigns?.heartRate || '',
      temperature: objective?.vitalSigns?.temperature || '',
    },

    // Array mapping - diagnoses to problem list
    problemList: (assessment?.diagnoses || []).map((dx: any) => ({
      description: dx.description,
      code: dx.icdCode,
      codeSystem: dx.icdCode ? 'ICD-10' : undefined,
    })),

    // Array mapping - medications to orders
    medicationOrders: (plan?.medications || []).map((med: any) => ({
      drugName: med.name,
      dose: med.dosage,
      frequency: med.frequency,
      instructions: med.instructions,
    })),

    // Single-value from plan
    followUp: plan?.followUp || '',
  };
}

// Usage
const soapJson = result.payload.json;
const ehrRecord = mapSoapToEHR(soapJson);

// Write to EHR system
await ehrClient.createEncounter(ehrRecord);

Handling Arrays vs Single Values

Some EHR systems expect single values where Sully.ai returns arrays. Handle these cases explicitly:
// Join array items into single string
const patientInstructions = (plan.patientEducation || []).join('\n');

// Take first item from array
const primaryDiagnosis = assessment.diagnoses?.[0]?.description || '';

// Filter and map array items
const prescriptions = plan.medications
  ?.filter((med: any) => med.name && med.dosage)
  .map((med: any) => `${med.name} ${med.dosage} ${med.frequency}`);

Integration Pipeline

A production EHR integration typically follows this pipeline:
Audio Upload → Transcription → Note Generation → JSON Extraction → Transform → Validate → EHR Write

Webhook-Based Pipeline

Use webhooks for production workloads to avoid polling and handle events asynchronously.
import express from 'express';
import { isValidSignature } from './webhook-utils';

const app = express();
app.use('/webhook', express.text({ type: '*/*' }));

// In-memory job tracking (use database in production)
const jobs = new Map<string, { transcriptionId?: string; noteId?: string; status: string }>();

app.post('/webhook', async (req, res) => {
  const signature = req.headers['x-sully-signature'] as string;
  const body = req.body as string;

  if (!isValidSignature(signature, body, process.env.SULLY_WEBHOOK_SECRET!)) {
    return res.status(403).send('Invalid signature');
  }

  const event = JSON.parse(body);

  try {
    switch (event.type) {
      case 'audio_transcription.succeeded':
        await handleTranscriptionComplete(event.data);
        break;

      case 'note_generation.succeeded':
        await handleNoteComplete(event.data);
        break;

      case 'audio_transcription.failed':
      case 'note_generation.failed':
        await handleFailure(event);
        break;
    }
  } catch (error) {
    console.error('Pipeline error:', error);
    // Log error but return 200 to prevent retries
  }

  res.status(200).send('OK');
});

async function handleTranscriptionComplete(data: any): Promise<void> {
  const { transcriptionId, payload } = data;
  console.log(`Transcription ${transcriptionId} complete, generating note...`);

  // Generate note from transcription
  const noteResponse = await fetch('https://api.sully.ai/v1/notes', {
    method: 'POST',
    headers: {
      'X-API-Key': process.env.SULLY_API_KEY!,
      'X-Account-Id': process.env.SULLY_ACCOUNT_ID!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      transcript: payload.transcription,
      noteType: { type: 'soap' },
    }),
  });

  const { data: noteData } = await noteResponse.json();
  jobs.set(transcriptionId, {
    transcriptionId,
    noteId: noteData.noteId,
    status: 'note_generating',
  });
}

async function handleNoteComplete(data: any): Promise<void> {
  const { id: noteId, payload } = data;
  console.log(`Note ${noteId} complete, writing to EHR...`);

  // Extract and transform JSON
  const soapJson = payload.json;
  const ehrRecord = mapSoapToEHR(soapJson);

  // Validate before writing
  const validationErrors = validateEHRRecord(ehrRecord);
  if (validationErrors.length > 0) {
    console.error('Validation failed:', validationErrors);
    await notifyValidationFailure(noteId, validationErrors);
    return;
  }

  // Write to EHR
  try {
    await writeToEHR(ehrRecord);
    console.log(`Successfully wrote note ${noteId} to EHR`);
  } catch (error) {
    console.error('EHR write failed:', error);
    await queueForManualReview(noteId, ehrRecord, error);
  }
}

async function handleFailure(event: any): Promise<void> {
  console.error(`${event.type}:`, event.data);
  await notifyFailure(event);
}

function validateEHRRecord(record: EHRRecord): string[] {
  const errors: string[] = [];

  if (!record.chiefComplaint) {
    errors.push('Chief complaint is required');
  }

  if (record.problemList.length === 0) {
    errors.push('At least one diagnosis is required');
  }

  // Add your EHR-specific validation rules
  return errors;
}

async function writeToEHR(record: EHRRecord): Promise<void> {
  // Implement your EHR API integration
  // Example: await ehrClient.encounters.create(record);
}

Error Handling at Each Step

Pipeline StepCommon ErrorsHandling Strategy
TranscriptionAudio format, quality issuesNotify user, request re-upload
Note GenerationInsufficient transcript contentFall back to manual note entry
JSON ExtractionMissing expected fieldsUse defaults, flag for review
TransformationSchema mismatchLog transformation errors, queue for manual mapping
ValidationRequired fields missingReject with specific error message
EHR WriteAPI errors, timeoutsRetry with backoff, queue for manual entry

Best Practices

Validate JSON Before Writing

Always validate extracted JSON before writing to your EHR:
import { z } from 'zod';

// Define validation schema matching your EHR requirements
const EHRRecordSchema = z.object({
  chiefComplaint: z.string().min(1, 'Chief complaint is required'),
  hpiNarrative: z.string().optional(),
  problemList: z.array(
    z.object({
      description: z.string().min(1),
      code: z.string().optional(),
    })
  ).min(1, 'At least one diagnosis required'),
  medicationOrders: z.array(
    z.object({
      drugName: z.string().min(1),
      dose: z.string().min(1),
      frequency: z.string().min(1),
    })
  ).optional(),
});

// Validate before EHR write
function validateForEHR(record: unknown): { success: boolean; errors?: string[] } {
  const result = EHRRecordSchema.safeParse(record);

  if (!result.success) {
    return {
      success: false,
      errors: result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`),
    };
  }

  return { success: true };
}

Handle Missing/Optional Fields

Not all fields will be present in every note. Handle missing data gracefully:
// Use nullish coalescing for defaults
const chiefComplaint = json.subjective?.chiefComplaint ?? 'Not documented';

// Provide empty arrays for missing lists
const medications = json.plan?.medications ?? [];

// Use optional chaining for nested access
const bloodPressure = json.objective?.vitalSigns?.bloodPressure;

// Check before using
if (bloodPressure) {
  ehrRecord.vitalSigns.bp = bloodPressure;
}

Log Transformations for Audit

Maintain an audit trail of all data transformations:
interface TransformationLog {
  timestamp: Date;
  noteId: string;
  sourceFields: string[];
  targetFields: string[];
  transformations: Array<{
    field: string;
    from: any;
    to: any;
    rule: string;
  }>;
}

function logTransformation(log: TransformationLog): void {
  // Write to your audit system
  console.log(JSON.stringify({
    type: 'ehr_transformation',
    ...log,
  }));
}

Use Templates for Predictable Output

For the most predictable JSON structure, use note templates with well-defined section IDs:
// Define template sections that map directly to EHR fields
const ehrOptimizedTemplate = {
  id: 'ehr-optimized',
  title: 'EHR-Optimized Template',
  sections: [
    {
      id: 'ehr-chief-complaint', // Predictable ID for extraction
      type: 'text',
      prompt: 'State the chief complaint in one sentence',
      properties: { max_sentences: 1 },
    },
    {
      id: 'ehr-problem-list', // Maps directly to EHR ProblemList
      type: 'list',
      prompt: 'List all diagnoses with ICD-10 codes if known',
      properties: { list_type: 'numeric' },
    },
    // ... more EHR-aligned sections
  ],
};

Next Steps