Luna App
Background
Luna is Nxtcure’s patient companion: a warm, role-bounded nursing presence that checks in with cancer patients at the end of each day, tracks their medications, goals, and symptoms, and connects them to clinical trials and a support community. The product spans two sibling repositories with a shared git history, both under and not source controlled in this monorepo: ~/void/www/git/medicalapps/, the Expo/React Native mobile client, and nxtcure-app, the FastAPI service behind it. The Luna concept was later ported into GalenOps as a retention and safety sensor for trial management; this page documents the original.nxtcureapp-backend
The Mobile App
is Expo SDK 54 on React Native 0.81 with React 19, TypeScript strict mode, and expo-router file-based routing. The nxtcure-app route files are nearly all one-liners; app/ is literallyapp/index.tsx
export { SplashScreen as default } from '@/features/screens';
with acting as the single registry mapping routes to implementations. The five-tab navigator uses a floating pill bar with emoji glyphs for four tabs and the Luna mascot for hers. The main surfaces: src/features/screens.tsx (1101 lines: home, scripted check-in chat, live voice call, and document discussion in one screen), luna.tsx (meds, symptoms, goals plus the check-in time picker), tracker.tsx (1013 lines: week and month grids, food scores, perfect days, streaks, trends, and a PDF report for appointments), scoreboard.tsx (clinical trial search), and a full matching.tsx tree of federated support groups.community/
State is one zustand store, , persisted to AsyncStorage with a versioned migration — with one deliberate exception: the refresh token lives only in src/store/useAppStore.ts, since AsyncStorage is plain text on disk. Entry routing is a pure function:expo-secure-store
export function landingRoute(state: {
token: string | null;
onboardingComplete: boolean;
consentVersion: string | null;
}): LandingRoute {
if (!state.token) return '/auth/sign-in';
if (!state.onboardingComplete) return '/onboarding/welcome';
if (needsConsent(state.consentVersion)) return '/onboarding/consent';
return '/(tabs)/luna';
}
App Store guidelines shaped the architecture in visible ways: email plus password is the only sign-in (offering any third-party login would require adding Sign in with Apple, so the app offers none, though the backend’s endpoint remains), billing is Apple IAP through /auth/google with Stripe kept off-device, analytics are double-gated behind a consent checkbox and iOS App Tracking Transparency, and account deletion is a first-class endpoint.expo-iap
Talking to the Backend
All HTTP flows through one authenticated fetch in , which retries exactly once after a silent token refresh and serializes concurrent refreshes so two 401s cannot each spend the refresh token:src/services/session.ts
export async function apiFetch(path: string, init: ApiInit = {}): Promise<Response> {
const { token, ...rest } = init;
const access = bridge?.getAccessToken() ?? token ?? null;
const res = await send(path, rest, access);
if (res.status !== 401) return res;
const fresh = await refreshAccessToken();
if (!fresh) return res; // session is over (or we're offline) — hand back the 401
return send(path, rest, fresh);
}
Feature services layer over it: chat, check-ins, tracker, transcripts, documents, clinical trials, billing, push devices, and a community repository with an interface, a real API client, and a mock selected by environment variable. The store and the session module avoid a circular import through a registered .AuthBridge
The Backend
is FastAPI on uvicorn, SQLAlchemy 2 over Cloud SQL Postgres, and nxtcureapp-backend for dependency management. The module docstring in uv states the layering rule the whole codebase follows:app/main.py
controller (app/controllers/) — the HTTP/websocket endpoint; thin
-> service (app/services/) — the business rules for the feature
-> repository (app/repositories/) — the only layer that touches the DB
The API surface groups into auth (, /auth/signup, /auth/login, /auth/refresh), the patient’s own data under /auth/logout (profile, consent, chat, tracker, check-ins, conversations, documents, trial matching, subscription, devices), around thirty five /me routes, billing webhooks, and one websocket, /community, for the live voice session. /ws/call returns a capability map rather than a bare ok so it doubles as a Docker liveness probe./health
The central data modelling idea is the recorded versus tracked split. The tracked tables are the patient’s plan, edited in the tracker tab and soft-deleted with ; the recorded tables are what the agent heard on a given day and are never edited by hand:active=False
class RecordedSymptom(Base):
"""A symptom rating the agent recorded on a given day."""
__tablename__ = "recorded_symptoms"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
day: Mapped[str] = mapped_column(String, index=True) # ISO YYYY-MM-DD
source: Mapped[str] = mapped_column(String, default="call") # 'call' | 'chat'
name: Mapped[str] = mapped_column(String)
value: Mapped[str] = mapped_column(String) # '0'-'4' rating
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
Days are bucketed in the patient’s own IANA timezone on both sides — the client persists a local day key with a migration that drops the old UTC key rather than misread it, and the check-in service stamps in the user’s zone.Checkin.day
Luna's Brain: Prompts and Providers
The LLM layer is provider agnostic. exposes app/llm.py, complete_json, and complete_chat, dispatching to OpenAI or Anthropic based on ocr_image (auto prefers OpenAI when both keys exist; defaults LLM_PROVIDER and gpt-4o-mini). Every path degrades to claude-opus-4-8 on failure, and keys are passed explicitly so an SDK can never fall back to a machine-level environment variable:None
def complete_json(system: str, user: str, max_tokens: int = 1024, model: str | None = None) -> str | None:
provider = config.active_llm_provider()
if provider == "openai":
return _openai(system, user, max_tokens, model)
if provider == "anthropic":
return _anthropic(system, user, max_tokens)
return None
There are two distinct Luna personas. carries the text chat system prompt: a warm companion limited to one or two sentences with a hard role fence and a twelve turn window. app/chat.py builds the voice persona per call from the patient’s real data, with untrusted input hardening (app/prompt.py, MAX_ITEMS, newline collapsing so a medication name cannot reshape the prompt):MAX_FIELD
return f"""You are Luna, a warm, calm end-of-day nursing companion for {name}, who is
a cancer patient. When you do the check-in your job is to ASK how their day went
and whether they did what they were supposed to do — you are checking in, NOT
giving reminders or instructions. Speak gently, ask only ONE question at a time,
keep each reply short (this is a spoken call), and always wait for the patient's
answer before moving on.
The Voice Call
The voice path bridges to the Deepgram Voice Agent: listen is Deepgram , think is OpenAI nova-3 running gpt-4o-mini, and speak is Deepgram build_prompt(ctx), all configured in one Settings frame in aura-2-aurora-en. The websocket controller authenticates before anything billable happens:app/deepgram_agent.py
# Authenticate BEFORE opening the Deepgram session. Every minute of that session
# is billable, so this has to gate the connect rather than merely decide whether
# the transcript gets attributed to a user.
claims = decode_token(start.get("token") or "")
if claims is None:
log.warning("Rejected unauthenticated call attempt")
await ws.send_json({"type": "error", "message": "Unauthorized — sign in again."})
await ws.close(code=1008) # policy violation
return
The client half opens the socket and sends the real tracker state so Luna asks about actual medications rather than a hardcoded list, reading the store at call time instead of subscribing to it (a lesson learned: the old subscription re-rendered every chat bubble on any store change anywhere in the app). On hangup the backend persists the transcript, runs structured extraction over it, and writes the recorded rows. The client uses specifically because its iOS player enables Apple’s hardware echo cancellation — without it Luna answers herself.@mykin-ai/expo-audio-stream
Check-Ins, Streaks, and the Scoreboard
The daily check-in has two paths that converge on the same data. The scripted chat builds a question queue from the active meds, goals, symptoms, and four meal slots, patching answers by item id so the tracker stays editable mid-conversation; on completion it submits the answers themselves rather than the transcript — the check-in is button driven, so re-deriving known answers through an LLM could only lose or distort them. The one thing buttons cannot give is how healthy the day’s food was, which the server asks the LLM to score zero to ten. The voice path counts as the day’s check-in only when it actually recorded answers; calling to ask a question leaves the check-in due. Once-a-day enforcement exists on both sides, and the voice controller reads check-in state from the database rather than trusting the client.
The scoreboard reconstructs history from : a perfect day is scored against the plan the day held, not today’s tracker, so deleting a goal cannot retroactively change the past. GET /me/logs walks back with string date arithmetic to compute the monthly count and current streak, treating today-in-progress as not yet breaking it. Four daily local notifications remind the patient, with a collision guard that drops rather than shifts an overlapping slot, and an option to keep medication names off the lock screen.computePerfectDays
Trial Matching
The tab drives a pipeline in matching, ported from an earlier JavaScript implementation: an LLM expands the condition into search terms for a ClinicalTrials.gov API v2 Essie query, a zip geocode bounds the radius, deterministic gates filter phase, type, sex, and age, and a per-trial LLM pass decomposes eligibility criteria into atomic items scored MET, NOT MET, or UNKNOWN with rationales, run concurrently and capped at sixty trials. Results are ranked and cached in app/services/matching/ tables keyed by a setup hash. The patient chart itself is never stored — it arrives with each request, feeds the prompt in memory, and contributes only to the cache key.MatchRun
Community
The support groups are a federated social layer bridged to a PieFed instance at . The bridge rules are a transcription of a SPIN/PROMELA formally verified model, with an in-process fake that executes the same model for development. Each user’s PieFed identity is derived by HMAC from their Nxtcure id, so no community password is ever stored, and accounts are provisioned in a background task after sign-in.social.nxtcure.com
Auth and Sessions
Passwords are PBKDF2-HMAC-SHA256 at 240,000 iterations. A login returns a thirty minute HS256 access JWT plus a ninety day sliding refresh token (one year hard ceiling) of which only the SHA-256 hash is stored. Rotations share a , and presenting an already-spent token revokes the entire family — theft detection. Paid features gate on a dependency that answers with the HTTP status that means exactly this:family_id
def require_subscriber(
claims: dict = Depends(get_current_claims),
db: Session = Depends(get_db),
) -> dict:
"""`get_current_claims`, plus proof the account is on a trial or paid.
Answers 402 for the free tier — the HTTP status that means exactly this —
with a message the app surfaces verbatim. `User.subscription_active`
already treats an expired trial as lapsed, so a stale mirror cannot hold
this gate open."""
from .models import User # noqa: PLC0415 — models imports nothing from here
user = db.get(User, claims["sub"])
if user is None or not user.subscription_active:
raise HTTPException(status_code=402, detail=SUBSCRIPTION_REQUIRED_MESSAGE)
return claims
The voice entitlement check fails open on a database outage — a blip must not hang up on a paying patient — but a definitive free-tier answer denies. Sign-out clears the keychain first and treats the logout request as best effort, so a network failure cannot leave a user stuck signed in.
Deployment
The backend ships as a two stage Docker image on with python:3.11-slim-bookworm cached by lockfile, a non-root user over root-owned read-only code, a stdlib healthcheck instead of curl, and uvicorn as PID 1 so SIGTERM drains open call sockets; uv sync --frozen sets compose.yml because the ten second default would kill a live Luna call mid-transcript. The stop_grace_period: 30s is an allow list, making it structurally impossible for .dockerignore to enter the image. Stripe, PieFed, push, and Apple verification all ship in-process fakes selected by environment variable, so the entire backend runs with no external accounts. The app itself has no Dockerfile: distribution is EAS Build and Submit for iOS, and because the audio stream module is custom native code, Expo Go cannot run it — a development build is required..env