Back to Build
Building
ai
clinical-core
DrChrono

Clinical Core: Charts, Encounters, Prescribing

Dr. Ben Soffer, DOJune 9, 202614 min read

Research and drafting assistance from Claude (Anthropic). All clinical, technical, and strategic decisions are mine.

Clinical Core: Charts, Encounters, Prescribing

The clinical core is the largest and most consequential piece of the application. It's where the work of practicing medicine actually happens, where the gap between "what the doctor wants" and "what the software does" is most expensive to get wrong, and where I've spent more engineering time per unit of functionality than anywhere else in the stack. This post is a tour of what I built and what I had to rebuild.

The post is framed against the telehealth psychiatry practice in Florida and New Jersey (post #4 covers the patient pipeline that delivers into this layer). The clinical core for psychiatry is its own shape: a smaller surface than primary care in terms of test ordering and imaging, but a much heavier surface in terms of controlled-substance prescribing, regular symptom-inventory monitoring, and safety alerts. The architecture below reflects those specifics.

DrChrono is the system of record, the application is not

The most important architectural decision in the clinical core is which system is authoritative for the chart. Most solo doctors who build their own application instinctively start by modeling the chart in their own database, because the data model feels controllable and the integration story with a third-party EHR feels intimidating. I did this for the first few months and it was the wrong call.

The right call is to let an established EHR (DrChrono in my case, but any modern EHR with a usable API works) be the system of record, and have the application read from it rather than maintain a parallel model. Charts, encounters, medications, problem lists, allergies, and prescriptions all live in DrChrono. The application stores its own data for things that aren't core clinical (patient pipeline state, appointment booking metadata, message threads, document-upload metadata) and references DrChrono for the rest.

Two reasons this matters. First, an EHR has compliance and clinical-data-handling features (audit logging, controlled-substance tracking, prescription transmission, e-signature workflows) that take months to build and years to make correct. Reproducing that surface in your own application is a bad use of solo-doctor engineering time. Second, the practice's actual liability for clinical data sits on the EHR's side once you've designated it the system of record; if your application database is corrupted or wiped, the patient charts are intact in the EHR. That's a meaningful disaster-recovery property.

The integration pattern: the application calls DrChrono's API on demand for views (admin patient chart, patient-portal chart summary) and writes back to DrChrono for actions the application owns (new encounter created from an application-side booking, message sent from the patient portal, etc.). There is no scheduled sync, no parallel mirror table. The DrChrono API is the source of truth for any query the application needs to answer about clinical data.

The shape of mistakes I had to retire: a PatientChart model in the application database that was being populated from intake forms, an Encounter model that tracked appointment state separately from DrChrono's appointment model, a Prescription model that recorded prescriptions written in the application that never made it into DrChrono. Each of these became a synchronization problem the moment the patient had a real visit and the chart needed to reconcile two sources. Removing them in favor of DrChrono-as-SoR was a few weeks of work that made the clinical core much smaller and much more correct.

The encounter loop

A visit moves through five states from the application's perspective:

  1. Scheduled. The patient has self-scheduled (post #4) and the appointment exists in DrChrono's calendar.
  2. In progress. The patient has joined the video session and I have started the visit. The application surfaces a "current encounter" view with chart context.
  3. Documented. The visit is over and I've written the chart note in DrChrono.
  4. Prescriptions sent. Any prescriptions written during the visit have been transmitted (or queued for transmission) through DrChrono's e-prescribing pipeline.
  5. Closed. Billing has been recorded, follow-up has been scheduled or queued, and the encounter is marked complete in the application's tracking.

The state machine for the encounter lives partially in DrChrono and partially in the application. DrChrono knows about scheduled, in progress, and documented. The application knows about prescriptions sent and closed, because those are post-visit workflows that wrap around the DrChrono interaction.

The reason to track the post-visit states in the application is operational. I want a dashboard that tells me "you have three encounters from this week that are documented but don't have prescriptions sent" or "you have one encounter from yesterday that's closed but the patient hasn't received the after-visit summary email." Those queries don't have natural answers in DrChrono because they cross application-owned actions and EHR-owned state. The application is the right place to track them.

The mechanism is a row per encounter in an EncounterTracking table that joins the DrChrono encounter ID with the application's view of post-visit work. When a webhook from DrChrono fires (encounter documented, prescription sent), the row gets updated. When the application performs a post-visit action (email sent, follow-up booked), the row gets updated. The row's status column is computed from the combination.

The async-friendly chart

A traditional chart is encounter-locked: data flows in during visits and the chart between visits is mostly inert. A modern outpatient practice doesn't work that way. Patients send messages, request refills, upload outside records, and have lab results delivered between visits. The chart needs to surface all of that to me when I sit down for the next encounter.

The pattern I use is simple: the chart view in the admin tool is composed at request time from DrChrono's chart data plus the application's between-visit artifacts. Messages from the patient portal, refill requests, uploaded documents, and any structured assessments completed asynchronously all render in a unified timeline alongside the formal encounter notes.

The key engineering choice: nothing is duplicated. Messages live in the application's message store, documents live in S3 with metadata in the application database, and clinical data lives in DrChrono. The chart view is a render-time aggregation that pulls from all three. If I update a chart note in DrChrono, the change shows up the next time the chart view loads. If a patient sends a message, the message shows up immediately. There's no cache-invalidation problem because there's no cache.

This is fast enough at current scale. It would not be fast enough at much higher volume and would need a caching layer at some point. The right time to add the caching layer is when chart-load latency starts to bother me, not before.

Document management: S3 signed URLs, not inline attachments

This is the section where I have to write about a specific mistake I made on the sister practice and learned not to repeat here.

The instinct, when a patient needs to receive a document (a treatment letter, an after-visit summary, an outside lab report I've reviewed), is to attach the PDF directly to an email and send it. It works for small files. It breaks for anything past a few megabytes, because email providers enforce size limits and the message bounces with an SMTP 552 error (the standard "message exceeds storage allocation" response). The bounce is often silent from the application's perspective: the message went into the queue, the queue returned success, and the failure happens at the receiving mail server hours later. The patient never gets the document and the doctor never finds out unless someone checks.

I learned this on the sister practice when an after-visit summary that included an embedded PDF for a multi-page form started bouncing for patients on certain email providers. The bounce notifications went into a queue I wasn't monitoring closely. Patients were calling to say they hadn't received their summary; I was looking at a green checkmark in the admin tool indicating it had been sent. The reconciliation took a couple of days and produced the rule I now follow everywhere.

The rule is: never attach a document inline to a patient email. Documents live in S3. Patient emails contain a signed URL that the patient clicks to download the document, and the URL is time-limited (typically 7 days). The patient gets a short email with a download button; the document itself is fetched directly from S3 when they click. Email message size stays tiny. Bounce risk disappears. As a bonus, the application records every URL fetch, so I can see whether a patient actually downloaded the document or never opened the email.

The signed-URL pattern adds two responsibilities. The application has to issue the signed URL at the right moment (link generation tied to the email send) and the application has to handle the case where a patient clicks a link past its expiry (re-issue a new link with a friendly "this link has expired, here's a fresh one" page). Both are straightforward. The alternative pattern, attaching documents inline and hoping nothing bounces, is a worse tradeoff at any meaningful scale.

The prescribing workflow

The prescribing workflow is where the clinical core touches the most external systems and where most of my operational bugs have lived.

The flow I use:

  1. During the visit, I write the prescription in DrChrono. DrChrono transmits it via its e-prescribing partner to the patient's pharmacy. This is the part DrChrono does well and I don't touch.
  2. After the visit, an admin tool I built called Pull Rx queries DrChrono for the prescriptions written during that encounter. It surfaces them in the admin view so I can see them as a list.
  3. I confirm the list looks right and trigger Send Email, which generates a patient email summarizing the prescriptions (drug name, dose, sig, expected pharmacy pickup time, refill information).
  4. The email goes out via SES with the signed-URL pattern for any attached treatment letter or supplementary document.

The Pull Rx step exists because DrChrono's notification flow to patients is generic and not branded; I wanted the patient to receive a single clean email from the practice that summarized everything, rather than a separate generic notification from DrChrono. The Send Email step is a one-click confirmation that the prescription list looks right before the patient gets the email.

The bug that consumed the most debugging time in this flow was pharmacy resolution. The patient's preferred pharmacy is captured during intake. When I write a prescription in DrChrono, DrChrono looks up the pharmacy by name and zip and routes the prescription. If the lookup fails (misspelled pharmacy name, address ambiguity, chain pharmacy with multiple matching locations), the original implementation in my application had a silent fallback: it would default to a "generic mail-order" pharmacy entry to avoid blocking the prescription.

The silent fallback was the wrong design. It looked like a robustness pattern and it acted like a footgun. Prescriptions would write successfully, the application would report success, and the prescription would route to the wrong pharmacy. The patient would arrive at their preferred location and be told their script wasn't there. The downstream call to figure out what happened took staff time and degraded patient trust.

The fix was to make the fallback explicit rather than silent. The pharmacy resolution step now either returns a confident match or surfaces a "this prescription needs pharmacy confirmation before transmission" flag that blocks the Send Email step and requires me to manually confirm the pharmacy. A blocked prescription is annoying. A silently misrouted prescription is much worse. The general principle: silent fallbacks are bugs disguised as features. Either resolve correctly or fail loudly.

Treatment letters and a separate Lambda

A meaningful percentage of patient requests in a psychiatry practice are for written letters: ESA letters, work or school excuses, DMV accommodations, travel documentation, prior-authorization support. These are not clinical encounters in the usual sense; they're document-generation requests that draw on the clinical chart.

The pattern I use: a separate Lambda generates the PDF on demand. The application sends a POST with the letter type, patient ID, and any free-text fields. The Lambda pulls the patient's name and current diagnosis from DrChrono, renders the letter from a template with the practice's letterhead and my signature image, and writes the resulting PDF to S3. The application then sends the patient a signed URL via the same email pattern as everything else.

The reason for a separate Lambda rather than rendering inside the application: PDF generation is memory-hungry and bursty. A Lambda scales to zero when not in use and doesn't share resources with the application's request-handling path. If the letter generation takes 8 seconds to render a complex multi-page template, the patient's portal experience doesn't hitch. The latency lives in its own process.

The template library lives in the same repository as the application, in a letters/templates/ directory. Each template is a TypeScript file that exports a function taking patient context and returning HTML for the letter. The Lambda renders the HTML to PDF using a headless browser. Each new letter type is a new file in the directory; deploying a new letter takes about ten minutes from "I have an idea for a letter type" to "patients can request it."

The MADRS item 10 alert

A note on the assessment instrument first. We started on the PHQ-9, which is the most common depression self-report in primary care and a reasonable place to begin. We switched to the Montgomery-Åsberg Depression Rating Scale (MADRS) once the practice was running and I'd had enough visits to know what I actually wanted to track. MADRS is the standard outcome measure in modern psychiatric drug trials, it's more sensitive to change at the symptom-severity range I actually see, and it produces cleaner trend data when I'm tracking response over time. The switch was a few days of work in the application: new template, new render in the chart timeline, retroactive migration of the prior PHQ-9 scores into a unified DepressionScore table so the trend line stays continuous across the change.

Item 10 of the MADRS is the suicidal-thoughts item, scored 0 to 6. A score above 0 is a clinical safety signal that needs to surface to the treating clinician immediately, not be filed quietly in the chart for the next visit. In the application, every MADRS submission (whether completed during intake, between visits using the self-rated version, or scored by me during a visit) runs through an alerting layer that checks item 10 specifically. If the patient endorses any positive answer, three things happen in parallel:

  1. The application sends me a text message via SMS with the patient's name and a "MADRS item 10 endorsed, severity X" summary. The text goes to my cell, not to a portal inbox, not to an email queue. Direct delivery.
  2. The patient sees a screen, immediately after submitting the MADRS, with crisis resources (988 Suicide and Crisis Lifeline, local emergency numbers, the practice's after-hours line) and clear instructions to call if they are in danger.
  3. The application creates a follow-up task in my admin view with high priority and the patient's chart context attached, so when I open the admin tool I see it at the top of the queue.

The reason for the three-channel response is that none of them is reliable enough alone. SMS can fail; the on-screen resources only help if the patient stays on the screen; the admin task only helps if I open the admin tool. All three together make it very unlikely that a positive answer escapes notice.

The principle generalizes: any clinical signal where missing it has serious consequences should be routed through multiple independent channels with explicit clinician-side notification. The mistake to avoid is the implicit assumption that "it'll show up in the chart" is a sufficient alerting mechanism. The chart is where you look during a visit. Safety signals need to surface before the next visit, by going to the clinician directly.

Next time

The next post covers the communications stack: SES patterns, AWS End User Messaging SMS, the hardcoded-fallback architecture that keeps outbound delivery working when configuration goes sideways, the Whereby Embedded video-visit integration, the patient portal, and the morning briefing / evening report / emergency escalation cron jobs that surface what's happening across the practice without requiring me to ask. The clinical core consumes messages and SMS as inputs; the next post is about how they get produced and routed.

Frequently Asked Questions

Why DrChrono as system of record instead of modeling the chart in the application?
An EHR has compliance and clinical-data-handling features (audit logging, controlled-substance tracking, prescription transmission, e-signature workflows) that take months to build and years to make correct. Reproducing them in your own application is a bad use of solo-doctor engineering time. Letting DrChrono own the chart also means the patient's clinical record survives an application database failure intact, which is a meaningful disaster-recovery property.
Why is the chart view computed at request time instead of cached?
Because nothing is duplicated. Messages live in the application's message store, documents live in S3, clinical data lives in DrChrono. The chart view is a render-time aggregation across three sources. There's no cache because there's no cache-invalidation problem. A caching layer becomes useful when chart-load latency starts to bother me; that's not now.
Why S3 signed URLs instead of attaching PDFs to emails directly?
Inline attachments hit provider size limits and bounce silently with SMTP 552 errors that the sending application reports as success. I lived through this on a sister practice when after-visit summaries with embedded PDFs started bouncing for patients on certain providers. The signed-URL pattern keeps email message size tiny, makes bounces impossible, gives the application a downloaded-yes-or-no signal, and handles document size with no upper bound.
What was the pharmacy silent-fallback bug?
When DrChrono's pharmacy lookup failed (misspelled name, address ambiguity, chain pharmacy with multiple locations), the original implementation silently defaulted to a generic mail-order pharmacy to avoid blocking the prescription. Prescriptions would write successfully, the application would report success, and the script would route to the wrong pharmacy. The fix was to make the fallback explicit: either a confident match or a 'needs pharmacy confirmation' flag that blocks transmission until I confirm. Silent fallbacks are bugs disguised as features. Either resolve correctly or fail loudly.
Why did you switch from PHQ-9 to MADRS?
PHQ-9 is the most common depression self-report in primary care and a reasonable place to start. MADRS (Montgomery-Åsberg Depression Rating Scale) is the standard outcome measure in modern psychiatric drug trials, more sensitive to change at the symptom-severity range I actually see in practice, and produces cleaner trend data when tracking response over time. The switch took a few days: new template, new render in the chart timeline, retroactive migration of prior PHQ-9 scores into a unified DepressionScore table so the trend line stays continuous across the change.
How are safety alerts routed when a patient endorses suicidal ideation?
Three channels in parallel: (1) SMS to my cell with the patient's name and severity, (2) on-screen crisis resources shown to the patient immediately after submission (988 Suicide and Crisis Lifeline, local emergency numbers, the practice's after-hours line), (3) a high-priority follow-up task in the admin view with chart context attached. The three-channel design exists because none of the individual channels is reliable enough alone. Any clinical signal where missing it has serious consequences should be routed through multiple independent channels with explicit clinician-side notification.
ai
clinical-core
DrChrono
prescribing
practice infrastructure
build log
telehealth-psychiatry
MADRS

If you're a doctor thinking about building (or fixing) your own practice tech and want to talk through your specific situation, I do a small amount of consulting at drbensoffer.com/consulting. I work with a handful of doctor-builders at a time, so the calendar is intentionally narrow.

Get the next post by email

One short email a week, only when there's a new post in this series.

One short email a week, only when there's a new post. Unsubscribe in one click.