How to build an outpatient app in an afternoon (behavioral health)

A start-to-finish walkthrough: create a patient, capture a PHQ-9 intake, write a clinical note, and render a live patient timeline — using clinical primitives that store FHIR R4 underneath, so your data is right from the first commit.

You can scaffold the screens for a behavioral-health app in an afternoon. The part that usually eats the next three weeks — modeling patients, assessments, and notes as clinical data that's actually portable and audit-safe — is the part you should not hand-roll. This guide shows the fast path: vibe-code the workflow on top of a clinical backend that already speaks FHIR.

How do you build an outpatient or behavioral-health app fast?

Build the UI with any AI coding tool, and put every clinical write through clinical primitives that persist as FHIR R4 underneath. Create the patient, capture intake as a QuestionnaireResponse, store notes as DocumentReference, and read a timeline with one query. The data is correct, audited, and exportable from day one.

This is the line that keeps you out of trouble: vibe-code the workflow, don't vibe-code the clinical data layer. Forms, screens, and navigation are great targets for AI. The shape of a patient record, the provenance of an assessment, and who's allowed to read a note are not — they need to be right, and FHIR R4 is the right way to make them right.

What you're building

A minimal but real outpatient flow for a solo or small behavioral-health practice:

  • A patient record — demographics you can extend later.
  • An intake — a PHQ-9 (depression) and GAD-7 (anxiety) captured at first contact, scored and stored.
  • A clinical note — a SOAP/DAP note written by the clinician.
  • A patient timeline — a live, chronological view of everything above.
  • A clinician view — a simple chart screen that pulls it together.

Every one of these maps to a FHIR R4 resource. You won't write FHIR by hand — the primitives do that — but knowing the mapping is what makes your app portable later. See the clinical primitives for the full list and how it works for the architecture.

Step 0 — Spin up the backend

One command scaffolds the project. bonfireDB is TypeScript + Postgres + pgvector with FHIR R4 underneath, and no Redis to operate.

the bonfire CLI (early access)

# then, in your app
import { clinical } from "bonfire-db";

The clinical client is the whole API surface for this guide. It's a typed SDK; every call below is a real write or read against your store.

Step 1 — Create the patient

Create the patient first; everything else hangs off the returned patientId. Under the hood this writes a FHIR Patient resource.

const patient = await clinical.patients.create({
  name: { given: "Jordan", family: "Rivera" },
  birthDate: "1991-07-02",
  contact: { email: "jordan@example.com" },
});

// patient.id is your handle for every clinical write below
const patientId = patient.id;

Step 2 — Capture intake as a QuestionnaireResponse

Store a completed PHQ-9 or GAD-7 as a FHIR QuestionnaireResponse, not as loose JSON columns. That one decision is the difference between an assessment you can trend, export, and defend — and a number you can't trust six months from now.

// Record the PHQ-9 the patient just completed at intake
const phq9 = await clinical.assessments.record("PHQ-9", {
  patientId,
  answers: [1, 2, 3, 2, 1, 0, 2, 1, 0],   // items 1–9, scored 0–3
});

console.log(phq9.totalScore);  // 12 → "moderate" severity band
console.log(phq9.severity);    // "moderate"

// Same call shape for anxiety
await clinical.assessments.record("GAD-7", {
  patientId,
  answers: [2, 2, 1, 2, 1, 1, 2],
});

The primitive computes the total, applies the standard severity band, and persists the individual item responses as a QuestionnaireResponse linked to the patient. PHQ-9 and GAD-7 are public-domain instruments, so you can ship them without licensing friction. When you later need to map free-text problems to standard codes, SNOMED CT is free for US use via the NLM UMLS license — see terminology.

Step 3 — Write a clinical note

Store a SOAP or DAP note as a FHIR DocumentReference. The note carries provenance — who wrote it, when — automatically, which is what makes it count as a clinical record rather than a text field.

const note = await clinical.notes.create({
  patientId,
  type: "progress-note",
  text: `S: Patient reports low mood and poor sleep for ~3 weeks.
O: PHQ-9 = 12 (moderate). Affect constricted, oriented x3.
A: Major depressive disorder, single episode, moderate.
P: Begin weekly CBT; reassess PHQ-9 in 4 weeks.`,
});

You can also record discrete clinical data — say a measured value you want to trend separately from the note — as a FHIR Observation:

await clinical.observations.record({
  patientId,
  code: "sleep-hours",
  value: 4.5,
  unit: "h",
});

Step 4 — Render a live patient timeline

Read everything you just wrote — patient, assessments, note, observation — as one reactive, chronological stream with useClinicalQuery. It's a live subscription: when a new note or score lands, the view updates without a manual refetch.

function PatientTimeline({ patientId }) {
  const { data: events, isLoading } = useClinicalQuery(
    "timeline",
    { patientId },
  );

  if (isLoading) return <Spinner />;

  return (
    <ol className="timeline">
      {events.map((e) => (
        <li key={e.id}>
          <time>{formatDate(e.occurredAt)}</time>
          <span>{e.title}</span>   {/* "PHQ-9: 12 (moderate)", "Progress note" */}
        </li>
      ))}
    </ol>
  );
}

This is the Convex-like part of the experience: you write to clinical primitives, and your React view stays in sync. The freshness is handled for you — no Redis, no manual cache invalidation. (More on the live-query model.)

Step 5 — A simple clinician chart view

Compose the same primitives into a chart screen. Pull the latest scores and the recent notes, and you have a working clinician view.

function ClinicianChart({ patientId }) {
  const { data: patient }   = useClinicalQuery("patient", { patientId });
  const { data: scores }    = useClinicalQuery("assessments", { patientId });
  const { data: notes }     = useClinicalQuery("notes", { patientId });

  return (
    <div className="chart">
      <h1>{patient?.name.given} {patient?.name.family}</h1>
      <ScoreTrend series={scores} />   {/* PHQ-9 / GAD-7 over time */}
      <NoteList notes={notes} />
    </div>
  );
}

Authorization is enforced in the data layer, not in this component. "Only this patient's own clinician can read these notes" is a rule the backend applies on every query — so a bug in your UI can't leak a chart. See primitives and the security model for how that ABAC layer is designed to work.

Going further: agents and export

Because the data is structured FHIR, two things you'd normally dread become one-liners.

An AI scribe or pre-visit assistant can read a grounded summary of the chart — and because the writes are clinical primitives, the agent works with real resources, not scraped text:

const brief = await clinical.agent.sessionPrep({ patientId });
// → grounded summary: recent scores, last note, open plan items
Honest note on AI: the agent drafts; the clinician decides. Treat sessionPrep output as a starting point a human reviews and signs — not a clinical conclusion. And any model API you send PHI to needs its own BAA, exactly like every other vendor in your data path.

And when a patient transfers care or you need to hand off records, export the whole chart as a standards-compliant FHIR bundle:

const bundle = await clinical.fhir.export(patientId);
// → FHIR R4 Bundle: Patient + QuestionnaireResponses + DocumentReferences + Observations

That's portability you get for free by storing FHIR from the start — instead of an afternoon's shortcut becoming a migration project later.

Honest framing: what this is, and isn't

  • bonfireDB is pre-launch / early access. The SDK shapes shown here are the product vision; join the waitlist to build on it early. No fabricated benchmarks, no "production at scale" claims yet.
  • FHIR storage has real incumbents. AWS HealthLake stores FHIR too. We compete on being indie- and pre-seed-friendly, BAA-from-day-one, and agent-native — not on pretending nobody else does this.
  • Code-first FHIR isn't a vacuum. Medblocks already reframed FHIR for code-first / AI builders. The bet here is a tighter, Convex-like developer experience for outpatient apps specifically.
  • A managed BAA covers your data layer — not your whole app. bonfireDB's managed offering signs a BAA for the clinical store. Every other vendor your app sends PHI to (your model API, analytics, logging) still needs its own.

Keep reading

TL;DR

  • Create the patient, then hang intake, notes, and observations off the returned patientId.
  • Intake → clinical.assessments.record("PHQ-9", …) stores a FHIR QuestionnaireResponse, scored and banded.
  • Notes → clinical.notes.create(…) stores a FHIR DocumentReference with provenance.
  • Timeline → useClinicalQuery("timeline", { patientId }) is a live, reactive read; no Redis, no manual refetch.
  • Authorization lives in the data layer; export is a single clinical.fhir.export(patientId).
  • Vibe-code the screens. Let the clinical primitives own the FHIR data. Early access — see pricing.
FAQ

Frequently asked questions

How do you build a behavioral-health or outpatient app fast?

Build the screens with any AI coding tool, but route every clinical write through typed clinical primitives that persist as FHIR R4 underneath. Create the patient, capture intake as a QuestionnaireResponse, store notes as a DocumentReference, and read a live timeline with one query. Vibe-code the workflow; don't vibe-code the data layer.

How do you store a PHQ-9 or GAD-7 intake so the score is trustworthy?

Store the completed assessment as a FHIR QuestionnaireResponse, not loose JSON columns. The primitive computes the total, applies the standard severity band, and persists each item linked to the patient. That is the difference between a score you can trend, export, and defend, and a number you can't trust six months later.

How do you render a live patient timeline without Redis or manual cache invalidation?

Read the patient, assessments, notes, and observations with a single reactive query. It's a live subscription: when a new note or score lands, the view updates without a manual refetch. The freshness is handled for you. This is the Convex-like read model bonfireDB is designed around, with no Redis to operate.

Where does authorization live in an outpatient app built this way?

In the data layer, not your React components. A rule like "only this patient's own clinician can read these notes" is applied by the backend on every query, so a bug in your UI can't leak a chart. That attribute-based access control is designed into the store, not bolted onto each screen.

Can an AI agent read the chart, and is that safe?

Yes. Because writes are clinical primitives, an agent works with real structured resources, not scraped text, and a session-prep call can return a grounded summary. The honest framing: the agent drafts, the clinician decides and signs. And any model API you send PHI to needs its own BAA, like every vendor in your data path.

Is bonfireDB production-ready, and how does it compare to HealthLake?

bonfireDB is pre-launch and in early access; the SDK shapes shown are the product vision, not production claims. AWS HealthLake also stores FHIR. The bet here is a tighter, Convex-like developer experience for outpatient apps: indie-friendly, BAA-from-day-one for the managed store, and agent-native, rather than pretending nobody else stores FHIR.

You build the app. Bonfire is the clinical data layer underneath.

bonfireDB is the open-source clinical backend for FHIR-safe outpatient apps — patients, assessments, notes, and live queries with FHIR R4 underneath. Pre-launch, early access.