Integrations¶
Curaway integrates with external services for voice input, multilingual support, currency conversion, video consultations, observability, and communication. Each integration is designed for incremental adoption — starting with free or built-in options and graduating to specialized services as usage grows.
Voice Input: Deepgram (3-Phase Plan)¶
Voice input allows patients to describe their medical situation naturally instead of typing. The implementation follows a three-phase plan, each gated by feature flags.
flowchart LR
P1[Phase 1<br/>Web Speech API<br/>CURRENT] --> P2[Phase 2<br/>OpenAI Whisper<br/>Feature-flagged]
P2 --> P3[Phase 3<br/>Deepgram Streaming<br/>WebSocket]
Phase 1: Web Speech API (Current)¶
Uses the browser's built-in speech recognition. Zero cost, zero backend changes, but limited accuracy and browser support (Chrome/Edge only).
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.continuous = false;
recognition.interimResults = true;
recognition.lang = userProfile.preferredLanguage || 'en-US';
recognition.onresult = (event) => {
const transcript = event.results[0][0].transcript;
setInputText(transcript);
};
recognition.onerror = (event) => {
if (event.error === 'not-allowed') {
showMicPermissionPrompt();
}
};
| Attribute | Value |
|---|---|
| Cost | Free |
| Accuracy | Moderate (browser-dependent) |
| Languages | Depends on browser/OS |
| Latency | Real-time (local processing) |
| Browser support | Chrome, Edge (partial Safari) |
Phase 2: OpenAI Whisper (Feature-Flagged)¶
Server-side transcription with higher accuracy. Audio is recorded on the client, sent to the backend, and transcribed via the Whisper API.
@router.post("/voice/transcribe")
@feature_flag("voice_whisper_enabled")
async def transcribe_audio(audio: UploadFile, claims: TokenClaims = Depends(get_claims)):
"""Transcribe audio using OpenAI Whisper."""
audio_bytes = await audio.read()
transcript = await openai_client.audio.transcriptions.create(
model="whisper-1",
file=("audio.webm", audio_bytes, audio.content_type),
language=claims.preferred_language[:2], # ISO 639-1
)
return {"text": transcript.text, "language": transcript.language}
Phase 3: Deepgram Streaming (Future)¶
Real-time streaming transcription via WebSocket for the lowest latency experience.
@router.websocket("/voice/stream")
async def voice_stream(websocket: WebSocket):
await websocket.accept()
claims = await authenticate_websocket(websocket)
async with deepgram.listen.asynclive.v("1") as dg_connection:
dg_connection.on(LiveTranscriptionEvents.Transcript, handle_transcript)
await dg_connection.start(LiveOptions(
model="nova-2-medical",
language=claims.preferred_language[:2],
smart_format=True,
punctuate=True,
))
while True:
audio_chunk = await websocket.receive_bytes()
await dg_connection.send(audio_chunk)
| Phase | Feature Flag | Status |
|---|---|---|
| 1 | N/A (always on) | Active |
| 2 | voice_whisper_enabled |
Ready, flag off |
| 3 | voice_deepgram_enabled |
Planned |
Multilingual Support¶
Curaway serves patients across 8+ countries. The multilingual strategy uses canonical English storage with edge translation for display.
Architecture¶
flowchart TD
A[Patient Input<br/>Any Language] --> B[LLM Translation<br/>to English]
B --> C[Canonical English Storage]
C --> D[ICD/SNOMED Codes<br/>Language-agnostic]
C --> E[LLM Translation<br/>to Patient Language]
E --> F[Patient Display<br/>Native Language]
Canonical English Storage¶
All medical data is stored in English as the canonical language. This ensures:
- Consistent matching across providers who operate in different languages
- Reliable ICD-10 and SNOMED CT code mapping
- Single source of truth for medical terminology
ICD/SNOMED Code Mapping¶
# Procedures and conditions are stored with standardized codes
procedure = {
"name": "Total Knee Replacement",
"icd_10": "0SRD0JZ",
"snomed_ct": "609588000",
"name_translations": {
"es": "Reemplazo total de rodilla",
"tr": "Total diz protezi",
"th": "การผ่าตัดเปลี่ยนข้อเข่า",
"ko": "인공슬관절 전치환술",
"ar": "استبدال الركبة بالكامل",
}
}
JSONB Name Translations¶
Provider and procedure names use a name_translations JSONB column for pre-computed
translations of common terms:
CREATE TABLE procedures (
id UUID PRIMARY KEY,
name VARCHAR(200) NOT NULL, -- Canonical English
icd_10_code VARCHAR(20),
snomed_code VARCHAR(20),
name_translations JSONB DEFAULT '{}', -- {"es": "...", "tr": "...", ...}
...
);
Edge Translation via LLM¶
For dynamic content (chat messages, descriptions), translation happens at the response layer using the conversation LLM:
async def translate_response(text: str, target_language: str) -> str:
"""Translate response to patient's preferred language."""
if target_language == "en":
return text
response = await llm_client.create(
model="claude-3-haiku-20240307",
messages=[{
"role": "user",
"content": f"Translate to {target_language}, preserving medical terms "
f"and a warm, professional tone:\n\n{text}"
}],
)
return response.content[0].text
Qdrant Cross-Lingual Search¶
Qdrant embeddings use the Voyage AI voyage-3.5-lite model, which supports multilingual
input. A query in Turkish will find relevant English-stored providers because the embedding
space maps semantically similar concepts close together regardless of language.
# Patient searches in Turkish: "diz protezi yapan hastaneler"
# Embedding maps this near "knee replacement hospitals" in vector space
results = qdrant.search(
collection_name="providers",
query_vector=embed("diz protezi yapan hastaneler"), # Turkish input
limit=10,
)
# Returns English-stored providers specializing in knee replacement
Multicurrency¶
Curaway displays costs in the patient's local currency while storing all amounts in the smallest unit of the provider's currency (e.g., USD cents) to avoid floating-point errors.
Storage Format¶
# All monetary values stored as integers in smallest currency unit
class ProcedureCost(BaseModel):
amount: int # e.g., 850000 (= $8,500.00)
currency: str # ISO 4217: "USD", "EUR", "TRY", etc.
@property
def display_amount(self) -> Decimal:
"""Convert to display format."""
return Decimal(self.amount) / Decimal(10 ** CURRENCY_EXPONENTS[self.currency])
ISO 4217 Currency Handling¶
# Exponents per ISO 4217
CURRENCY_EXPONENTS = {
"USD": 2, # cents
"EUR": 2,
"GBP": 2,
"TRY": 2, # kurus
"INR": 2, # paise
"THB": 2, # satang
"MXN": 2, # centavos
"KRW": 0, # no subdivision
"AED": 2, # fils
"CRC": 2, # centimos
}
Frankfurter API for Display-Time Conversion¶
Exchange rates are fetched daily via QStash cron and cached in Redis. Conversion happens only at display time — stored amounts never change.
async def refresh_exchange_rates():
"""Daily cron: fetch latest rates from Frankfurter API."""
response = await httpx.get(f"{FRANKFURTER_API_URL}/latest?from=USD")
rates = response.json()["rates"]
for currency, rate in rates.items():
await redis.set(
f"exchange_rate:USD:{currency}",
str(rate),
ex=90000, # 25 hours TTL (overlap with daily refresh)
)
async def convert_for_display(amount: int, from_currency: str, to_currency: str) -> dict:
"""Convert amount for display only. No floating-point math on stored values."""
if from_currency == to_currency:
return {"amount": amount, "currency": from_currency}
rate_str = await redis.get(f"exchange_rate:{from_currency}:{to_currency}")
rate = Decimal(rate_str)
converted = int(Decimal(amount) * rate)
return {
"amount": converted,
"currency": to_currency,
"rate": str(rate),
"rate_date": datetime.utcnow().date().isoformat(),
"disclaimer": "Approximate conversion. Final amount determined at time of payment.",
}
No Floating-Point Math
All currency calculations use Decimal or integer arithmetic. Python float is never
used for monetary values to avoid rounding errors (e.g., 0.1 + 0.2 != 0.3).
Video Consultations: Daily.co¶
Video consultations connect patients with providers for pre-travel assessments. Daily.co provides HIPAA-eligible video infrastructure with 10,000 free minutes per month.
Data Model¶
CREATE TABLE consultations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(100) NOT NULL,
patient_id UUID NOT NULL REFERENCES patients(id),
provider_id UUID NOT NULL REFERENCES providers(id),
doctor_id UUID REFERENCES doctors(id),
daily_room_name VARCHAR(200),
daily_room_url TEXT,
status VARCHAR(20) DEFAULT 'scheduled', -- scheduled, active, completed, cancelled
scheduled_at TIMESTAMPTZ NOT NULL,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
duration_seconds INTEGER,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE consultation_participants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
consultation_id UUID NOT NULL REFERENCES consultations(id),
user_id VARCHAR(100) NOT NULL,
role VARCHAR(20) NOT NULL, -- patient, doctor, interpreter
joined_at TIMESTAMPTZ,
left_at TIMESTAMPTZ,
duration_seconds INTEGER
);
Room Creation¶
async def create_consultation_room(consultation_id: str) -> dict:
"""Create a Daily.co room for a consultation."""
response = await httpx.post(
"https://api.daily.co/v1/rooms",
headers={"Authorization": f"Bearer {DAILY_API_KEY}"},
json={
"name": f"curaway-{consultation_id[:8]}",
"privacy": "private",
"properties": {
"enable_recording": "cloud",
"enable_chat": True,
"exp": int((datetime.utcnow() + timedelta(hours=2)).timestamp()),
"max_participants": 4,
"enable_knocking": True,
},
},
)
room = response.json()
return {"room_name": room["name"], "room_url": room["url"]}
Video SDK Deferred
The actual Daily.co video SDK integration in the frontend is deferred. The backend creates rooms and manages scheduling, but the patient-facing video UI is a future milestone.
MCP Protocol¶
Curaway exposes a Model Context Protocol (MCP) server that allows external AI systems to interact with platform data through a standardized tool interface.
Exposed Tools¶
| Tool | Description | Parameters |
|---|---|---|
search_providers |
Semantic provider search | query, country, procedure_type |
get_provider_details |
Full provider profile | provider_id |
check_procedure_availability |
Procedure availability at provider | provider_id, procedure_id |
get_travel_requirements |
Visa and travel requirements | origin_country, destination_country |
estimate_cost |
Cost estimate with currency conversion | procedure_id, provider_id, currency |
get_patient_status |
Current patient journey status | patient_id (requires auth) |
MCP Server Configuration¶
from mcp import Server
mcp_server = Server("curaway-medical-travel")
@mcp_server.tool()
async def search_providers(query: str, country: str = None, procedure_type: str = None):
"""Search for medical providers matching patient needs."""
results = await provider_search_service.search(
query=query,
country=country,
procedure_type=procedure_type,
)
return [
{
"provider_id": r.id,
"name": r.name,
"country": r.country,
"city": r.city,
"specialties": r.specialties,
"accreditations": r.accreditations,
"match_score": r.score,
}
for r in results
]
Email: Resend¶
Resend handles transactional email delivery with 3,000 free emails per month.
Email Templates¶
| Template | Trigger | Content |
|---|---|---|
match_ready |
Provider matching completes | Top 3 matched providers with summary |
intake_reminder |
Incomplete intake (hourly cron) | Gentle nudge to complete medical intake |
consent_expiring |
Consent version outdated (daily cron) | Prompt to review updated terms |
Sending Implementation¶
import resend
resend.api_key = RESEND_API_KEY
async def send_email(template: str, to_email: str, data: dict):
"""Send a transactional email via Resend."""
template_fn = EMAIL_TEMPLATES[template]
html_content = template_fn(data)
resend.Emails.send({
"from": "Curaway <hello@curaway.com>",
"to": [to_email],
"subject": EMAIL_SUBJECTS[template],
"html": html_content,
})
await audit_log(
tenant_id=data["tenant_id"],
actor_id="system",
action="email.sent",
resource_type="email",
details={"template": template, "to": to_email},
)
Push Notifications (Stubbed)¶
Device registration for FCM (Android) and APNs (iOS) is stubbed in the data model, ready for implementation when mobile apps launch.
CREATE TABLE device_registrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES patients(id),
tenant_id VARCHAR(100) NOT NULL,
platform VARCHAR(10) NOT NULL, -- 'fcm' or 'apns'
device_token TEXT NOT NULL,
is_active BOOLEAN DEFAULT true,
registered_at TIMESTAMPTZ DEFAULT NOW(),
last_seen_at TIMESTAMPTZ
);
PostHog Analytics¶
PostHog provides product analytics with strict PHI safety measures.
Configuration¶
import posthog
posthog.api_key = POSTHOG_API_KEY
posthog.host = "https://app.posthog.com"
# CRITICAL: Identify users by UUID only — never PII
posthog.identify(
distinct_id=patient.id, # UUID, not email or name
properties={
"tenant_id": patient.tenant_id,
"country": patient.country, # OK: not PII
"preferred_language": patient.lang, # OK: not PII
# NEVER: name, email, phone, medical info
},
)
Autocapture Disabled¶
// posthog-js configuration
posthog.init(POSTHOG_API_KEY, {
api_host: 'https://app.posthog.com',
autocapture: false, // DISABLED: prevents capturing PHI in form fields
capture_pageview: true, // Page views are safe
capture_pageleave: true,
mask_all_text: true, // Extra safety: mask text in session recordings
mask_all_element_attributes: true,
});
PHI Safety
Autocapture is disabled because it would capture form field contents, which may contain medical information. All tracking events are explicitly defined with reviewed properties.
Langfuse: LLM Observability¶
Langfuse provides production tracing for all LLM interactions, including cost tracking and prompt management.
Trace Integration¶
from langfuse import Langfuse
langfuse = Langfuse(
public_key=LANGFUSE_PUBLIC_KEY,
secret_key=LANGFUSE_SECRET_KEY,
host=LANGFUSE_HOST,
)
async def generate_response(message: str, session_id: str, tenant_id: str) -> str:
trace = langfuse.trace(
name="conversation_turn",
session_id=session_id,
metadata={"tenant_id": tenant_id},
)
# Classification span
classification = trace.span(name="input_classification")
category = await classify_message(message)
classification.end(output={"category": category})
# Generation span
generation = trace.generation(
name="response_generation",
model="claude-sonnet-4-20250514",
input=[{"role": "user", "content": message}],
)
response = await llm_client.create(messages=messages)
generation.end(
output=response.content[0].text,
usage={"input": response.usage.input_tokens, "output": response.usage.output_tokens},
)
return response.content[0].text
Cost Tracking¶
Langfuse automatically calculates costs based on token usage and model pricing. The dashboard shows daily/weekly/monthly LLM spend broken down by trace type.
Prompt Management¶
System prompts are versioned in Langfuse with production/staging labels. The backend fetches the current production prompt at startup and caches it.
Timezone Handling¶
All timestamps are stored in UTC. Conversion to the user's local timezone happens at the response layer.
Storage¶
-- All TIMESTAMPTZ columns store UTC
ALTER TABLE patients ADD COLUMN timezone VARCHAR(50) DEFAULT 'UTC';
-- Example: 'America/New_York', 'Asia/Istanbul', 'Asia/Bangkok'
Conversion at Response Layer¶
from zoneinfo import ZoneInfo
def to_user_timezone(utc_dt: datetime, timezone_str: str) -> str:
"""Convert UTC datetime to user's timezone for display."""
user_tz = ZoneInfo(timezone_str)
local_dt = utc_dt.replace(tzinfo=ZoneInfo("UTC")).astimezone(user_tz)
return local_dt.isoformat()
# Usage in response
{
"consultation": {
"scheduled_at_utc": "2026-04-15T14:00:00Z",
"scheduled_at_local": to_user_timezone(
consultation.scheduled_at, patient.timezone
),
"timezone": patient.timezone,
}
}
Cron Jobs and UTC¶
All QStash cron schedules are in UTC. When a cron job needs to operate in user-local time (e.g., sending digest emails at "8 AM local"), it queries patients grouped by timezone and processes each group at the appropriate UTC offset.