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 ~/void/www/git/medicalapps/galenops and is not source controlled in this monorepo.

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:

Only the backend has a Repo. 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 Req wrapper. The portal version reads as:

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/:

Two more contexts carry the differentiator: Galenops.HumanLoop is the review queue, and Galenops.Orchestration is the agent-run audit trail plus a twelve-step study lifecycle checklist. Finally Galenops.Luna treats the participant companion as a retention and safety sensor: chat messages with a classified intent, check-ins rating feeling 1 through 5, tracked items, item logs, and appointments. All schemas use binary_id primary keys and UTC timestamps.

Automation Engine

Galenops.Automation is a single 1089 line module of pure functions over the domain: 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, predict_retention/1, and watch_termination_risk/1. The heuristics are not hand waving: the module carries the SPIRIT 2013/2025 protocol completeness checklist as @spirit_checklist, a fourteen day query-aging SLA as @query_aging_sla_days, and a kit buffer of 1.15 for supply forecasting. Each red flag rule in watch_termination_risk/1 is traceable to a citation in 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, Galenops.Integrations marks the build-versus-partner boundary with three simulated vendor surfaces: MatchingEngine for EHR/RWD candidate matching, SupplyVendor for IRT/RTSM forecasting, and PaymentsVendor for milestone site payments. The matching engine never auto-enrolls; the recruitment agent always opens an import_matched_candidates review task.

Agent Orchestration

Galenops.Orchestration.Pipeline in lib/galenops/orchestration/pipeline.ex is the coordinator. An ordered list of thirteen agents walks the study lifecycle: feasibility_agent, protocol_agent, site_activation_agent, recruitment_agent, monitoring_agent, cdm_agent, safety_agent, retention_agent, termination_watch_agent, supply_agent, biostats_agent, medical_writing_agent, and tmf_agent. Each agent is a defp run_agent(:name, study) clause returning {:done, reasoning, task_or_nil} or {:skip, reasoning}, calling into Galenops.Automation and 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 AgentRun with a plain-language reasoning string, an outcome of completed, escalated, skipped, or failed, and a pointer to the review task when one was opened.

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}

Galenops.Orchestration.checklist/1 then derives the portal-facing twelve step checklist by joining the latest AgentRun per agent against its ReviewTask, yielding not_started, attention, awaiting_review, or complete per step.

Human In The Loop

Galenops.HumanLoop in lib/galenops/human_loop.ex is the other half of the contract. Agents open tasks through one function, deduplicated so pipeline re-runs do not stack duplicate escalations:

  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 ReviewTask.action string: an approved approve_protocol_draft marks the protocol approved, a rejection returns it to draft, and a serious adverse event is never auto-closed on rejection. Known actions include 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, resolve_agent_exception, and the three Luna escalations luna_crisis_escalation, luna_severe_symptom, and luna_withdrawal_signal. A companion module HumanLoop.TaskContext polymorphically assembles details, consequences, agent reasoning, and links per subject type so the reviewer can decide well from one screen.

Luna

Luna is the participant side, ported from the NxtCure app. Galenops.Luna.Brain 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.

The one live external AI dependency is Deepgram, voice only and optional. Galenops.Luna.Deepgram makes two plain Req calls: speech to text against /v1/listen with nova-3, and text to speech against /v1/speak with aura-2-aurora-en. Without a DEEPGRAM_API_KEY the voice endpoint reports disabled and text chat is unaffected. A spoken turn is one round trip: audio in, transcribe, route through the same Luna.chat/2 path as typed messages, speak the reply, return base64 audio.

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 galenops_backend/lib/galenops_web/router.ex exposes RESTful resources per pillar, plus the orchestration, review queue, Luna, and automation routes:

    # 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: Galenops.PdfWriter is a dependency-free PDF renderer (Courier, US Letter) for protocol, CSR, and TLF previews served at GET /api/documents/:id/pdf, and Galenops.Literature 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.

Demo Data and Seeding

The seed pipeline has three layers. Galenops.Release.maybe_seed/0 is the boot-time guard, wired into compose.yml as the backend command so first boot migrates, seeds if the database is empty, then serves:

  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

priv/repo/seeds.exs creates the demo study GAL-001: Adaptive Phase II in Refractory Melanoma, 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.

Galenops.DemoImport then ingests real-scale CDISC SDTM extracts from priv/demo_data/*.json, produced by scripts/extract_sdtm_study.py (pandas plus pyarrow, run under uv): 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 reported so a live triage queue exists, and major protocol deviations become open QA findings feeding the site KRIs.

Regulatory Readiness

We’re building the compliant AI workspace for regulated labs, made for GMP, GLP and 21 CFR Part 11 from day one … Every tool that touches a study has to be validated first, which is exactly why AI has passed them by.
--Ordo Labs launch post (YC F26)

That post names four regulatory items: GMP, GLP, 21 CFR Part 11, and validation of every tool that touches a study. This section is the plan for GalenOps to be able to make the same claim honestly, and to survive a customer’s vendor audit when it does. It is a working plan, not legal advice; the scoping decisions in Phase 0 should be confirmed with a regulatory/QA consultant before money is spent on Phases 3 and 4.

What can and cannot be obtained

None of the four is a certificate a software company is issued, and saying “GMP certified” or “Part 11 certified” about software is a red flag to any QA auditor. Each one works differently:

GalenOps runs clinical trials rather than labs, so the predicate rule it actually lives under is GCP — ICH E6(R3), which added explicit expectations for computerised systems — not GMP or GLP. GMP and GLP matter only if we sell into manufacturing QC or non-clinical (preclinical) labs, which is Ordo Labs’ market. Part 11 and validation apply either way. So what GalenOps can obtain is:

  1. a Part 11 / Annex 11 compliance assessment of the product, clause by clause, with evidence;
  2. a validation package customers can adopt (GAMP 5 / CSA);
  3. a quality management system (QMS) that makes both repeatable and auditable;
  4. independent evidence: a successful customer audit or mock inspection, and, because every vendor questionnaire asks, a security attestation (SOC 2 Type II or ISO 27001).

Phase 0: Scope

Phase 1: Quality management system

Auditors read the QMS before they look at the software. The minimum set of SOPs for a GxP software vendor:

ISO 9001 certification is optional, but it is a recognised external signal that the QMS runs. Exit: SOPs approved and in effect, with training records for everyone who touches the code.

Phase 2: Part 11 technical controls in GalenOps

GalenOps already has the right posture — every medical, safety, regulatory or financial judgement lands in a human review queue, and every agent execution is persisted as an AgentRun with its reasoning. What it does not yet have is a Part 11-grade record of who decided what and when. The gap list, by clause:

Exit: a clause-by-clause Part 11 / Annex 11 matrix for GalenOps, each row pointing to the code, test or SOP that satisfies it.

Phase 3: Validation package

Deliver a package a customer’s QA can adopt instead of writing their own:

Exit: the first release shipped with a complete package, and the package reviewed by an independent CSV consultant.

Phase 4: Independent evidence

Order and dependencies

Phase 0 comes first and is cheap. Phase 1 and Phase 2 run in parallel, because the audit trail and e-signature work is engineering while the SOPs are writing. Phase 3 needs Phase 2′s controls to exist before they can be tested. Phase 4 needs everything before it. The single most important engineering item is the append-only audit trail with signature linking on ReviewTask decisions: it is the part a Part 11 auditor tests first, and the one GalenOps’ human-in-the-loop design already points towards.

Deployment

Bring-up is one command from the repository root, with secrets in a gitignored .env generated via mix phx.gen.secret:

docker compose up -d --build

The backend healthcheck curls /api/dashboard with a 600 second start period because first boot seeds before listening; the portal, mobile, and Luna services gate on condition: service_healthy. SQLite persists in a backend_data volume and migrations run on every boot. Local development is the standard mix setup && mix phx.server per app.

The docs/architecture/ directory carries six Graphviz control-flow diagrams (system context, request flow, orchestration, human loop, module map, boot), a Touying typst slide deck in slides.typ compiled to a 16:9 PDF, and a build.sh that renders the dot sources and compiles the deck. The literature review in docs/intent/lit_review_synthesis.md maps each implemented heuristic back to its citation.