import { WorkerOptions, defineAgent, llm, voice, VAD } from '@livekit/agents'; import * as google from '@livekit/agents-plugin-google'; import * as silero from '@livekit/agents-plugin-silero'; import { z } from 'zod'; // Platform GraphQL helper const SIDECAR_URL = process.env.SIDECAR_URL ?? 'http://localhost:4100'; const PLATFORM_API_KEY = process.env.PLATFORM_API_KEY ?? ''; async function gql(query: string, variables?: Record): Promise { if (!PLATFORM_API_KEY) return null; try { const res = await fetch(`${SIDECAR_URL}/graphql`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${PLATFORM_API_KEY}` }, body: JSON.stringify({ query, variables }), }); const data = await res.json(); if (data.errors) { console.error('[AGENT-GQL] Error:', data.errors[0]?.message); return null; } return data.data; } catch (err) { console.error('[AGENT-GQL] Failed:', err); return null; } } // Resolve a phone to a {leadId, patientId} pair via the sidecar's // caller-resolution endpoint. Always returns populated IDs (creates // placeholder lead+patient when none exist). async function resolveCaller(phone: string): Promise<{ leadId: string; patientId: string; firstName: string; lastName: string; isNew: boolean } | null> { try { const res = await fetch(`${SIDECAR_URL}/api/caller/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone }), }); if (!res.ok) { console.error('[AGENT-RESOLVE] Failed:', res.status, await res.text().catch(() => '')); return null; } return await res.json(); } catch (err) { console.error('[AGENT-RESOLVE] Failed:', err); return null; } } // Hospital context — loaded on startup let hospitalContext = { doctors: [] as Array<{ name: string; department: string; specialty: string; id: string }>, departments: [] as string[], }; async function loadHospitalContext() { const data = await gql(`{ doctors(first: 20) { edges { node { id fullName { firstName lastName } department specialty } } } }`); if (data?.doctors?.edges) { hospitalContext.doctors = data.doctors.edges.map((e: any) => ({ id: e.node.id, name: `Dr. ${e.node.fullName?.firstName ?? ''} ${e.node.fullName?.lastName ?? ''}`.trim(), department: e.node.department ?? '', specialty: e.node.specialty ?? '', })); hospitalContext.departments = [...new Set(hospitalContext.doctors.map(d => d.department))] as string[]; console.log(`[LIVEKIT-AGENT] Loaded ${hospitalContext.doctors.length} doctors, ${hospitalContext.departments.length} departments`); } else { // Fallback hospitalContext.doctors = [ { id: '', name: 'Dr. Arun Sharma', department: 'Cardiology', specialty: 'Interventional Cardiology' }, { id: '', name: 'Dr. Rajesh Kumar', department: 'Orthopedics', specialty: 'Joint Replacement' }, { id: '', name: 'Dr. Meena Patel', department: 'Gynecology', specialty: 'Reproductive Medicine' }, { id: '', name: 'Dr. Lakshmi Reddy', department: 'General Medicine', specialty: 'Internal Medicine' }, { id: '', name: 'Dr. Harpreet Singh', department: 'ENT', specialty: 'Head & Neck Surgery' }, ]; hospitalContext.departments = ['Cardiology', 'Orthopedics', 'Gynecology', 'General Medicine', 'ENT']; console.log('[LIVEKIT-AGENT] Using fallback doctor list'); } } // ─── Tools ──────────────────────────────────────────────────────────── const lookupDoctor = llm.tool({ description: 'Look up available doctors by department or specialty. Call this when the patient asks about a specific department or type of doctor.', parameters: z.object({ department: z.string().nullable().describe('Department name like Cardiology, Orthopedics, ENT'), specialty: z.string().nullable().describe('Specialty or condition like joint pain, heart, ear'), }), execute: async ({ department, specialty }) => { let results = hospitalContext.doctors; if (department) { results = results.filter(d => d.department.toLowerCase().includes(department.toLowerCase())); } if (specialty) { results = results.filter(d => d.specialty.toLowerCase().includes(specialty.toLowerCase()) || d.department.toLowerCase().includes(specialty.toLowerCase()), ); } if (results.length === 0) return 'No matching doctors found. Available departments: ' + hospitalContext.departments.join(', '); return results.map(d => `${d.name} — ${d.department} (${d.specialty})`).join('\n'); }, }); const bookAppointment = llm.tool({ description: 'Book an appointment for the caller. You MUST collect patient name, phone number, department, preferred date/time, and reason before calling this.', parameters: z.object({ patientName: z.string().describe('Full name of the patient'), phoneNumber: z.string().describe('Patient phone number with country code'), department: z.string().describe('Department for the appointment'), doctorName: z.string().nullable().describe('Preferred doctor name if specified'), preferredDate: z.string().describe('Date in YYYY-MM-DD format or natural language'), preferredTime: z.string().describe('Time slot like 10:00 AM, morning, afternoon'), reason: z.string().describe('Reason for visit'), }), execute: async ({ patientName, phoneNumber, department, doctorName, preferredDate, preferredTime, reason }) => { console.log(`[LIVEKIT-AGENT] Booking: ${patientName} | ${phoneNumber} | ${department} | ${doctorName ?? 'any'} | ${preferredDate} ${preferredTime}`); // Parse date — try ISO format first, fallback to tomorrow let scheduledAt: string; try { const parsed = new Date(preferredDate); if (!isNaN(parsed.getTime())) { // Map time to hour const timeMap: Record = { morning: '10:00', afternoon: '14:00', evening: '17:00' }; const timeStr = timeMap[preferredTime.toLowerCase()] ?? preferredTime.replace(/\s*(AM|PM)/i, (_, p) => ''); scheduledAt = new Date(`${parsed.toISOString().split('T')[0]}T${timeStr}:00`).toISOString(); } else { scheduledAt = new Date(Date.now() + 86400000).toISOString(); // tomorrow } } catch { scheduledAt = new Date(Date.now() + 86400000).toISOString(); } // Find matching doctor const doctor = doctorName ? hospitalContext.doctors.find(d => d.name.toLowerCase().includes(doctorName.toLowerCase())) : hospitalContext.doctors.find(d => d.department.toLowerCase().includes(department.toLowerCase())); // Create appointment on platform const result = await gql( `mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`, { data: { name: `AI Booking — ${patientName} (${department})`, scheduledAt, status: 'SCHEDULED', doctorName: doctor?.name ?? doctorName ?? 'To be assigned', department, reasonForVisit: reason, ...((doctor as any)?.clinicId ? { clinicId: (doctor as any).clinicId } : {}), }, }, ); // Resolve caller — if isNew, create Lead + Patient with the // AI-collected name; otherwise update the existing record. const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10); const resolved = await resolveCaller(cleanPhone); const fn = patientName.split(' ')[0]; const ln = patientName.split(' ').slice(1).join(' ') || ''; if (resolved?.isNew) { const p = await gql( `mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`, { data: { fullName: { firstName: fn, lastName: ln }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } }, ); const newPatientId = p?.createPatient?.id; await gql( `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, { data: { name: `AI — ${patientName}`, contactName: { firstName: fn, lastName: ln }, contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, source: 'PHONE', status: 'APPOINTMENT_SET', interestedService: department, ...(newPatientId ? { patientId: newPatientId } : {}), }, }, ); } else if (resolved?.leadId) { await gql( `mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, { id: resolved.leadId, data: { name: `AI — ${patientName}`, contactName: { firstName: fn, lastName: ln }, source: 'PHONE', status: 'APPOINTMENT_SET', interestedService: department, }, }, ); if (resolved.patientId) { await gql( `mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`, { id: resolved.patientId, data: { fullName: { firstName: fn, lastName: ln } } }, ); } } const refNum = `GH-${Date.now().toString().slice(-6)}`; if (result?.createAppointment?.id) { console.log(`[LIVEKIT-AGENT] Appointment created: ${result.createAppointment.id}`); return `Appointment booked successfully! Reference number ${refNum}. ${patientName} is scheduled for ${department} on ${preferredDate} at ${preferredTime} with ${doctor?.name ?? 'an available doctor'}. A confirmation SMS will be sent to ${phoneNumber}.`; } return `I have noted the appointment request. Reference number ${refNum}. Our team will confirm the booking and send an SMS to ${phoneNumber}.`; }, }); const collectLeadInfo = llm.tool({ description: 'Save the caller as a lead/enquiry when they are interested but not ready to book. Collect their name and phone number.', parameters: z.object({ name: z.string().describe('Caller name'), phoneNumber: z.string().describe('Caller phone number'), interest: z.string().describe('What they are interested in or enquiring about'), }), execute: async ({ name, phoneNumber, interest }) => { console.log(`[LIVEKIT-AGENT] Lead: ${name} | ${phoneNumber} | ${interest}`); const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10); const resolved = await resolveCaller(cleanPhone); const fn = name.split(' ')[0]; const ln = name.split(' ').slice(1).join(' ') || ''; if (resolved?.isNew) { // Net-new caller — create Patient + Lead with the AI-collected name. const p = await gql( `mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`, { data: { fullName: { firstName: fn, lastName: ln }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } }, ); const newPatientId = p?.createPatient?.id; const created = await gql( `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, { data: { name: `AI Enquiry — ${name}`, contactName: { firstName: fn, lastName: ln }, contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, source: 'PHONE', status: 'NEW', interestedService: interest, ...(newPatientId ? { patientId: newPatientId } : {}), }, }, ); console.log(`[LIVEKIT-AGENT] Lead created: ${created?.createLead?.id ?? 'none'} (patient ${newPatientId ?? 'none'})`); } else if (resolved?.leadId) { await gql( `mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, { id: resolved.leadId, data: { name: `AI Enquiry — ${name}`, contactName: { firstName: fn, lastName: ln }, source: 'PHONE', status: 'NEW', interestedService: interest, }, }, ); if (resolved.patientId) { await gql( `mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`, { id: resolved.patientId, data: { fullName: { firstName: fn, lastName: ln } } }, ); } console.log(`[LIVEKIT-AGENT] Lead updated: ${resolved.leadId} (patient ${resolved.patientId})`); } return `Thank you ${name}. I have noted your enquiry about ${interest}. One of our team members will call you back on ${phoneNumber} shortly.`; }, }); const transferToAgent = llm.tool({ description: 'Transfer the call to a human agent. Use this when the caller explicitly asks to speak with a person, or when the query is too complex.', parameters: z.object({ reason: z.string().describe('Why the caller needs a human agent'), }), execute: async ({ reason }) => { console.log(`[LIVEKIT-AGENT] Transfer requested: ${reason}`); // TODO: When SIP is connected, trigger Ozonetel transfer via sidecar API return 'I am transferring you to one of our agents now. Please hold for a moment. If no agent is available, someone will call you back within 15 minutes.'; }, }); // ─── Agent ──────────────────────────────────────────────────────────── const hospitalAgent = new voice.Agent({ instructions: `You are the AI receptionist for Global Hospital, Bangalore. Your name is Helix. PERSONALITY: - Warm, professional, and empathetic - Speak clearly and at a moderate pace - Use simple language — many callers may not be fluent in English - Be concise — this is a phone call, not a chat - Respond in the same language the caller uses (English, Hindi, Kannada) CAPABILITIES: - Answer questions about hospital departments, doctors, and specialties - Book appointments — collect: name, phone, department, preferred date/time, reason - Take messages and create enquiries for callback - Transfer to a human agent when needed HOSPITAL INFO: - Global Hospital, Bangalore - Open Monday to Saturday, 8 AM to 8 PM - Emergency services available 24/7 - Departments: ${hospitalContext.departments.join(', ') || 'Cardiology, Orthopedics, Gynecology, General Medicine, ENT'} RULES: - Greet: "Hello, thank you for calling Global Hospital. This is Helix, how may I help you today?" - If caller asks about pricing, say you will have the team call back with details - Never give medical advice — always recommend consulting a doctor - If the caller is in an emergency, tell them to visit the ER immediately or call 108 - Always confirm all details before booking an appointment - End calls politely: "Thank you for calling Global Hospital. Have a good day!" - If you cannot understand the caller, politely ask them to repeat`, llm: new google.beta.realtime.RealtimeModel({ model: 'gemini-2.5-flash-native-audio-latest', voice: 'Aoede', temperature: 0.7, }), tools: { lookupDoctor, bookAppointment, collectLeadInfo, transferToAgent }, }); // ─── Entry Point ────────────────────────────────────────────────────── export default defineAgent({ prewarm: async (proc) => { proc.userData.vad = await silero.VAD.load(); await loadHospitalContext(); }, entry: async (ctx) => { await ctx.connect(); console.log(`[LIVEKIT-AGENT] Connected to room: ${ctx.room.name}`); const session = new voice.AgentSession({ vad: ctx.proc.userData.vad as VAD, }); await session.start({ agent: hospitalAgent, room: ctx.room }); console.log('[LIVEKIT-AGENT] Voice session started'); // Gemini Realtime handles greeting via instructions — no separate say() needed }, }); // CLI runner if (require.main === module) { const options = new WorkerOptions({ agent: __filename, }); const { cli } = require('@livekit/agents'); cli.runApp(options); }