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.
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 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
- Build an AI scribe in a weekend — turn a session transcript into a grounded, clinician-signed note.
- Why build on FHIR at all? — the case for FHIR underneath, without the operating burden on top.
TL;DR
- Create the patient, then hang intake, notes, and observations off the returned
patientId. - Intake →
clinical.assessments.record("PHQ-9", …)stores a FHIRQuestionnaireResponse, scored and banded. - Notes →
clinical.notes.create(…)stores a FHIRDocumentReferencewith 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.