GalenOps
Background
GalenOps is a rudimentary implementation of the NxtCure AI-CRO vision: automate the repeatable, document-heavy Contract Research Organization functions in phased horizons, and build the company around a cross-functional agent orchestration layer that coordinates every module end to end. The repository lives at and is not source controlled in this monorepo.~/void/www/git/medicalapps/galenops
A rudimentary implementation of the NxtCure AI-CRO vision: automate the repeatable, document-heavy CRO functions in phased horizons, keep a human-in-the-loop as a feature, not a limitation, and build the company around the one durable differentiator — a cross-functional agent orchestration layer that coordinates every module end to end and escalates to a human only on exception.
--GalenOps README
What is an agent? UNIX + LLM. GalenOps takes the reductionist reading seriously: the “AI” in the product is deterministic Elixir with explicit seams marked for models, so trial management gets guarantees first and inference later. Every automated judgment that is medical, safety, regulatory, or financial in nature terminates in a human review queue rather than a side effect.
Applications
The repository is a four-app Phoenix 1.8 monorepo. There is no umbrella; four independent Mix projects are wired together over HTTP:
(port 4000): Phoenix JSON API with Ecto over SQLite (galenops_backend). All domain logic, all state, the automation modules, the orchestration layer, and the review queue.ecto_sqlite3(port 4010): LiveView sponsor/CRO operations console styled with daisyUI.galenops_portal(port 4020): mobile-first LiveView PWA for field workflows — review queue, safety triage, site monitoring. A drafted LiveView Native SwiftUI host sits ingalenops_mobilebut is blocked onnative/pinning Phoenixlive_view_native 0.4.0-rc.1.~> 1.7(port 4030): Luna, the standalone patient companion app ported from the NxtCure mobile app — chat, structured check-ins, a medication and symptom tracker with a scoreboard, and optional voice.galenops_luna
Only the backend has a . The portal, mobile, and Luna apps hold no state of their own; each is a pure client of the backend JSON API through a thin Repo wrapper. The portal version reads as:Req
defmodule GalenopsPortal.Backend do
@moduledoc """
Thin HTTP client for the Galenops backend JSON API.
All portal LiveViews go through this module; the portal holds no state of
its own. Functions return plain decoded maps; `{:error, reason}` on
transport or non-2xx responses.
"""
def dashboard, do: get("/dashboard")
def studies, do: get_data("/studies")
def study(id), do: get_data("/studies/#{id}")
def create_study(attrs), do: post("/studies", %{study: attrs})
With the HTTP plumbing at the bottom of the module amounting to little more than:
defp get(path, params \\ []) do
handle(Req.get(base_url() <> path, params: params, retry: false))
end
defp handle({:ok, %Req.Response{status: status, body: body}}) when status in 200..299,
do: {:ok, body}
defp handle({:ok, %Req.Response{status: status, body: body}}),
do: {:error, "backend returned #{status}: #{inspect(body)}"}
defp handle({:error, exception}),
do: {:error, "backend unreachable: #{Exception.message(exception)}"}
This is the entire integration surface between the four services. No shared database, no message bus, no service mesh. Well-ordered filesystem, efficient automation.
Domain Model
The backend organizes its Ecto contexts into the four pillars of CRO work, in :galenops_backend/lib/galenops/
(Pillar 1, study start-up and design): studies, versioned protocols, feasibility assessments, regulatory submissions, and site contracts.Galenops.Studies(Pillar 2, execution): sites with a KRIGalenops.ClinicalOps, pseudonymous patients keyed byrisk_score, monitoring visits, and supply forecasts.external_ref(Pillar 3, data and safety): EDC data queries and adverse events.Galenops.DataSafety(Pillar 4, documentation and quality): TMF/CSR/TLF documents, QA findings, and site payments.Galenops.Quality
Two more contexts carry the differentiator: is the review queue, and Galenops.HumanLoop is the agent-run audit trail plus a twelve-step study lifecycle checklist. Finally Galenops.Orchestration treats the participant companion as a retention and safety sensor: chat messages with a classified Galenops.Luna, check-ins rating feeling 1 through 5, tracked items, item logs, and appointments. All schemas use intent primary keys and UTC timestamps.binary_id
Automation Engine
is a single 1089 line module of pure functions over the domain: Galenops.Automation, draft_protocol/1, assess_feasibility/2, forecast_supply/2, score_site_risk/1, triage_adverse_event/1, resolve_data_query/1, draft_csr/1, generate_data_queries/1, generate_tlfs/1, audit_tmf/1, check_site_activation/2, and predict_retention/1. The heuristics are not hand waving: the module carries the SPIRIT 2013/2025 protocol completeness checklist as watch_termination_risk/1, a fourteen day query-aging SLA as @spirit_checklist, and a kit buffer of 1.15 for supply forecasting. Each red flag rule in @query_aging_sla_days is traceable to a citation in watch_termination_risk/1.docs/intent/lit_review_synthesis.md
The escalation posture is visible directly in the code. Adverse event triage never lets software decide causality:
def triage_adverse_event(%AdverseEvent{} = ae) do
if ae.serious or ae.severity == "severe" do
{:ok, ae} = DataSafety.update_adverse_event(ae, %{status: "under_review"})
{:ok, task} =
HumanLoop.open_task(
"adverse_event",
ae.id,
"assess_serious_event",
"Serious/severe adverse event requires safety physician assessment: #{String.slice(ae.description || "", 0, 140)}",
"critical"
)
{:ok, %{adverse_event: ae, review_task: task}}
else
{:ok, ae} =
DataSafety.update_adverse_event(ae, %{
status: "assessed",
assessment:
"Auto-assessed as non-serious (#{ae.severity}). Included in aggregate safety reporting; no expedited report required."
})
{:ok, %{adverse_event: ae, review_task: nil}}
end
end
Alongside the automation engine, marks the build-versus-partner boundary with three simulated vendor surfaces: Galenops.Integrations for EHR/RWD candidate matching, MatchingEngine for IRT/RTSM forecasting, and SupplyVendor for milestone site payments. The matching engine never auto-enrolls; the recruitment agent always opens an PaymentsVendor review task.import_matched_candidates
Agent Orchestration
in Galenops.Orchestration.Pipeline is the coordinator. An ordered list of thirteen agents walks the study lifecycle: lib/galenops/orchestration/pipeline.ex, feasibility_agent, protocol_agent, site_activation_agent, recruitment_agent, monitoring_agent, cdm_agent, safety_agent, retention_agent, termination_watch_agent, supply_agent, biostats_agent, and medical_writing_agent. Each agent is a tmf_agent clause returning defp run_agent(:name, study) or {:done, reasoning, task_or_nil}, calling into {:skip, reasoning} and Galenops.Automation.Galenops.Integrations
The main loop wraps every agent so that a crash is itself an escalation, never a silent failure:
def run_study_pipeline(%Study{} = study) do
runs = for {agent, horizon} <- @steps, do: execute(agent, horizon, study)
{:ok, runs}
end
defp execute(agent, horizon, study) do
{outcome, reasoning, task} =
try do
case run_agent(agent, study) do
{:done, reasoning, nil} -> {"completed", reasoning, nil}
{:done, reasoning, task} -> {"escalated", reasoning, task}
{:skip, reasoning} -> {"skipped", reasoning, nil}
end
rescue
exception ->
{:ok, task} =
HumanLoop.open_task(
"study",
study.id,
"resolve_agent_exception",
"Agent #{agent} crashed on \"#{study.title}\": #{Exception.message(exception)}. Orchestration continued; this step needs human follow-up.",
"high"
)
{"failed", "Unhandled exception: #{Exception.message(exception)}", task}
end
Every execution is persisted as an with a plain-language AgentRun string, an outcome of reasoning, completed, escalated, or skipped, and a pointer to the review task when one was opened.failed
The safety agent illustrates the do/skip/escalate shape and closes with a sentence worth keeping:
_ ->
{:done,
"Triaged #{length(reported)} adverse event(s); #{length(escalated)} serious/severe escalated to the safety physician. Causality is never decided by software.",
task}
then derives the portal-facing twelve step checklist by joining the latest Galenops.Orchestration.checklist/1 per agent against its AgentRun, yielding ReviewTask, not_started, attention, or awaiting_review per step.complete
Human In The Loop
in Galenops.HumanLoop is the other half of the contract. Agents open tasks through one function, deduplicated so pipeline re-runs do not stack duplicate escalations:lib/galenops/human_loop.ex
def pending_task_exists?(subject_type, subject_id, action) do
Repo.exists?(
from t in ReviewTask,
where:
t.subject_type == ^subject_type and t.subject_id == ^subject_id and
t.action == ^action and t.status == "pending"
)
end
def open_task(subject_type, subject_id, action, summary, risk_level) do
create_review_task(%{
subject_type: subject_type,
subject_id: subject_id,
action: action,
summary: summary,
risk_level: risk_level,
status: "pending"
})
end
Approval and rejection side effects are keyed on the string: an approved ReviewTask.action marks the protocol approve_protocol_draft, a rejection returns it to approved, and a serious adverse event is never auto-closed on rejection. Known actions include draft, approve_protocol_draft, approve_csr_draft, assess_serious_event, resolve_data_query, submit_regulatory, release_site_payment, validate_tlf_outputs, confirm_onsite_visit, review_low_feasibility, import_matched_candidates, assess_termination_risk, and the three Luna escalations resolve_agent_exception, luna_crisis_escalation, and luna_severe_symptom. A companion module luna_withdrawal_signal polymorphically assembles details, consequences, agent reasoning, and links per subject type so the reviewer can decide well from one screen.HumanLoop.TaskContext
Luna
Luna is the participant side, ported from the NxtCure app. is where an LLM would be: the NxtCure chat system prompt has been ported into deterministic rules — ordered keyword precedence over crisis, severe symptom, withdrawal, medication, visit, and status phrase lists — so the role contract (never a doctor, crisis resources first, one-sentence deflection out of scope) is enforced in code rather than in a prompt. An LLM can be slotted behind the same intents later.Galenops.Luna.Brain
The one live external AI dependency is Deepgram, voice only and optional. makes two plain Galenops.Luna.Deepgram calls: speech to text against Req with /v1/listen, and text to speech against nova-3 with /v1/speak. Without a aura-2-aurora-en the voice endpoint reports disabled and text chat is unaffected. A spoken turn is one round trip: audio in, transcribe, route through the same DEEPGRAM_API_KEY path as typed messages, speak the reply, return base64 audio.Luna.chat/2
In the LiveView, escalation surfaces to the participant as a flash while the review task lands in the CRO queue in real time:
case Backend.chat(user["id"], text) do
{:ok, %{"reply" => reply, "escalated" => escalated}} ->
sent = %{"role" => "user", "content" => text, "escalated" => escalated}
socket =
socket
|> assign(transcript: socket.assigns.transcript ++ [sent, reply], error: nil)
|> then(fn s ->
if escalated,
do: put_flash(s, :error, "Escalated to the study team — a human will follow up."),
else: s
end)
{:noreply, socket}
API Surface
The router at exposes RESTful galenops_backend/lib/galenops_web/router.ex per pillar, plus the orchestration, review queue, Luna, and automation routes:resources
# Horizon 3 — cross-functional agent orchestration + audit trail
post "/studies/:id/orchestrate", OrchestrationController, :run
get "/studies/:id/checklist", OrchestrationController, :checklist
resources "/agent_runs", AgentRunController, except: [:new, :edit]
# Human-in-the-loop review queue (grouped route must precede :id routes)
get "/review_tasks/grouped", ReviewTaskController, :grouped
resources "/review_tasks", ReviewTaskController, except: [:new, :edit]
get "/review_tasks/:id/context", ReviewTaskController, :context
post "/review_tasks/:id/approve", ReviewTaskController, :approve
post "/review_tasks/:id/reject", ReviewTaskController, :reject
# Automation endpoints — each proposes work and routes risk to a human
post "/studies/:id/automation/draft_protocol", AutomationController, :draft_protocol
post "/studies/:id/automation/assess_feasibility", AutomationController, :assess_feasibility
post "/studies/:id/automation/forecast_supply", AutomationController, :forecast_supply
post "/studies/:id/automation/draft_csr", AutomationController, :draft_csr
Two smaller modules round out the backend: is a dependency-free PDF renderer (Courier, US Letter) for protocol, CSR, and TLF previews served at Galenops.PdfWriter, and GET /api/documents/:id/pdf provides pharmacovigilance mechanism evidence through NCBI E-utilities, honestly separated into always-available reproducible PubMed search links and best-effort live article lookups that degrade to empty on network failure. Never fabricated citations.Galenops.Literature
Demo Data and Seeding
The seed pipeline has three layers. is the boot-time guard, wired into Galenops.Release.maybe_seed/0 as the backend command so first boot migrates, seeds if the database is empty, then serves:compose.yml
def maybe_seed do
cond do
System.get_env("SEED_ON_BOOT", "false") not in ~w(true 1 yes) ->
IO.puts("SEED_ON_BOOT disabled — skipping seed.")
not empty?() ->
IO.puts("SEED_ON_BOOT: database already has studies — skipping seed.")
true ->
IO.puts("SEED_ON_BOOT: empty database — seeding demo data...")
seed()
end
end
creates the demo study priv/repo/seeds.exs, deliberately behind enrollment pace so the termination watch agent raises an accrual flag, walks it through every automation function pillar by pillar, seeds three Luna participants with distinct narratives (a perfect-day streak, missed medications with withdrawal talk, symptom ratings), and finishes by running the full orchestration pipeline so the reasoning trail and review queue are populated on first page load.GAL-001: Adaptive Phase II in Refractory Melanoma
then ingests real-scale CDISC SDTM extracts from Galenops.DemoImport, produced by priv/demo_data/*.json (pandas plus pyarrow, run under scripts/extract_sdtm_study.py): a synthetic Phase 3 NSCLC study with 450 subjects and 2,840 adverse events, and the CDISC pilot Alzheimer’s study with 306 subjects. The import mapping keeps the safety agent honest — the five most recent serious adverse events stay uv so a live triage queue exists, and major protocol deviations become open QA findings feeding the site KRIs.reported
Deployment
Bring-up is one command from the repository root, with secrets in a gitignored generated via .env:mix phx.gen.secret
docker compose up -d --build
The backend healthcheck curls with a 600 second start period because first boot seeds before listening; the portal, mobile, and Luna services gate on /api/dashboard. SQLite persists in a condition: service_healthy volume and migrations run on every boot. Local development is the standard backend_data per app.mix setup && mix phx.server
The directory carries six Graphviz control-flow diagrams (system context, request flow, orchestration, human loop, module map, boot), a Touying typst slide deck in docs/architecture/ compiled to a 16:9 PDF, and a slides.typ that renders the dot sources and compiles the deck. The literature review in build.sh maps each implemented heuristic back to its citation.docs/intent/lit_review_synthesis.md