ConsultBridge.
A family physician gets two or three specialist consult letters a day, and each one costs about fourteen minutes of reading, reconciling and re-typing. ConsultBridge is my team's answer from the Hackers & Healers AI in Healthcare Co-Design Hackathon (June 2026): open a letter, and a model turns it into medication changes carrying their own evidence, follow-up tasks, an EMR note and a patient letter — every one of them reviewed, edited and committed by the physician. My teammates built the pipeline; I built the interface. This page takes the interface apart.
It was a weekend build, and it stops at the repo — the pieces below are rebuilt from that app source.
ConsultBridge
Thank you for referring Mrs. Thompson, a 74-year-old female with a longstanding history of hypertension, type 2 diabetes (on metformin 1000mg BID), and newly diagnosed heart failure with reduced ejection fraction (HFrEF, EF 35%).
ASSESSMENT & PLAN
- Initiating Entresto (sacubitril/valsartan) 49/51mg BID. Discontinue her current lisinopril 10mg — do not overlap, wait 36 hours before starting Entresto.
- Adding empagliflozin (Jardiance) 10mg daily — excellent evidence in HFrEF and also addresses her diabetes.
- Increasing furosemide from 20mg to 40mg daily for volume management.
- Please monitor potassium and creatinine in 2 weeks given medication changes.
- TaskBook potassium + creatinine blood work within 2 weeks
- TaskSchedule repeat echocardiogram in 3 months
74-year-old female with hypertension, type 2 diabetes, and newly diagnosed heart failure with
Commit this consult?
Approve 4 med changes · Create 3 tasks · Commit 1 patient letter
Three panes
A specialist's consult letter is a wall of prose hiding the same few questions every time: what changed, what do I need to do, and what does the patient need to hear. ConsultBridge answers them on one screen, and the layout is the argument. The inbox on the left is the letter library — rows carry a specialty and a word count until they've been processed, and swap to their outcome (4 Changes, 3 tasks) once they have. The letter in the middle is the source of truth, untouched. The generated result on the right is the work: a summary, the medication changes, the patient letter, the tasks.
The right pane shows its work at every step. Every medication change is a diff — OLD, NEW, and a REF row whose text is the specialist's own sentence; hover it and that sentence lights up in the middle pane, click and the letter scrolls to it. Every change takes an explicit Approve / Defer / Reject, the patient letter is editable in place, and the commit button won't enable until each change has a decision. The model drafts; the physician decides. That division of labour is the whole product.
One honest detail in that diff: the OLD row is always an em dash. The backend reports the action to take, and only that — so the row stays empty and the REF line carries the whole justification. It's exactly the kind of gap a demo is tempted to paper over.
One token file
Every colour, radius, shadow and easing in the pieces above comes from one file:
tokens.css, 64 custom properties, the app's single source of truth. Every
value in it has a provenance. The surfaces and chip pairings are transcribed from the Figma
spec; the four motion curves were measured frame-by-frame from reference captures the
repo keeps notes on — a voice-recording pill, a sandbox boot card, a recurrence picker,
a delete dialog, each broken down to per-frame timings before any component borrowed
its curve.
/* tokens.css — motion (measured from replicas). Every curve in the app is one of these four; nothing animates on an ad-hoc bezier. */--out: cubic-bezier(.16, 1, .3, 1); /* entrances / open / reveal */--in: cubic-bezier(.4, 0, 1, 1); /* exits / close */--spring: cubic-bezier(.34, 1.56, .64, 1); /* pop overshoot */--spring-soft: cubic-bezier(.34, 1.3, .5, 1);
This page ships that exact file. The shelf's pieces are scoped
under a single block that is tokens.css verbatim, Inter and all. If a chip
colour looks slightly off to you here, it's off in the app too.
Grounding
The colour system's real job is cross-pane linking. A highlight ships as a triad — a
resting wash, a -strong hover shade, an -ink for text — and
the medication chips take their colours straight from those triads:
/* tokens.css — the letter <-> AI link colours. A highlight is a triad: the resting wash, a -strong hover/flash shade, an -ink for chip text. The med chips own no colours at all — they alias the triads, so a STOP chip and the sentence it cites can never drift apart. */--hl-red: #ffd6d9; --hl-red-strong: #ffb3b9; --hl-red-ink: #b23b36;--hl-green: #e9f8f1; --hl-green-strong: #c8eedd; --hl-green-ink: #0a8a5e;--hl-cream: #fef7eb; --hl-cream-strong: #fbe9c8; --hl-cream-ink: #9a6a13;--hl-blue: #8dc6f9; --hl-blue-strong: #6fb4f4; --hl-blue-ink: #0f3f6b;--med-stop-bg: var(--hl-red); --med-stop-ink: var(--hl-red-ink);--med-start-bg: var(--hl-green); --med-start-ink: var(--hl-green-ink);--med-modify-bg: var(--hl-cream); --med-modify-ink: var(--hl-cream-ink);--med-dose-bg: var(--hl-blue); --med-dose-ink: var(--hl-blue-ink);So the red STOP chip, the red wash under "lisinopril 10mg — do not overlap", and the red ink that chip types in are one decision, aliased into three places.
The more interesting half is that each highlight is made the moment the model answers. A letter arrives as an undifferentiated blob of text — real mtsamples transcriptions have no paragraph breaks at all, so they're first split into sections by anchoring on ALL-CAPS headings followed by a colon. Then, for each medication change, the app goes looking for the evidence the model quoted and marks it in place:
// letters.js — attachHighlights, compressed. Nothing in the letter is// pre-marked: for each medication the model returns, its evidence is// located in the letter text and a highlight is injected there. Matching// is best-first — the quoted phrase, then the raw evidence, then the drug// name alone, because mtsamples evidence often spans a section heading// and won't survive re-blocking, while the drug name still will.const candidates = [m.phrase, m.evidence, m.drug_name].filter(Boolean)for (const cand of candidates) { const idx = block.text.toLowerCase().indexOf(cand.toLowerCase()) if (idx === -1) continue const overlap = used[bi].find((u) => start < u.end && end > u.start) if (overlap) idRemap[id] = overlap.id // two meds, one sentence else block.hl.push({ id, phrase: block.text.slice(start, end), color }) placed = true}The fallback ladder is the tell of something built against real data. A model quoting a clean referral letter gives you a sentence you can find verbatim; a model quoting mtsamples gives you a fragment that spans a heading the re-blocker just moved. Falling back to the drug name alone means the link degrades to roughly here instead of vanishing. And when two medications cite the same sentence — an Entresto start and a lisinopril stop, in one line — the second can't get its own span, so it's remapped onto the first and both rows still scroll to the same evidence.
Designing the wait
The pipeline is slow in a way a demo can't hide: a long letter through a 12B model is a 60-second stare. Almost every interesting decision in the right pane is about that minute. Four sections share one clock and three states — queued, processing, done — with a grey ring while waiting, a spinning arc and a live rolling-digit timer while working, a springing check when finished. When a newer section completes the older one demotes: its ring slides away, its timer fades, and the title glides left, so only the most recently finished section keeps its time on screen as a receipt. The Section shell piece on the shelf plays that loop live.
Two states exist purely because the backend is real. Only one letter is processed at a time, so selecting a second one puts you in an explicit Queued state that names the wait and promises to start itself. And when the backend simply isn't there, the pane names the endpoint it couldn't reach and offers Retry — during a hackathon demo, "couldn't reach the backend" is a state you will be in, on stage.
My favourite detail is the progress bar, which has to span two incompatible kinds of waiting — a slow, real, server-side analysis, then a fast, cosmetic reveal:
// AIPane.jsx. The backend takes 60s+ on a long letter; the reveal// choreography takes ~8. Giving each its own bar would rewind the// progress the reader had already watched fill, so the analyze phase// owns 0–90% and the reveal owns the last 10% — one monotonic bar// across two completely different kinds of waiting.const barPct = liveError || queued ? 0 : preResult ? (live?.progress || 0) : 0.9 + progress * 0.1Giving each phase its own bar would rewind progress the reader had already watched fill. Ninety percent for the wait, ten for the show, and the bar only ever moves right. The same instinct runs through the rest: the leading section's ring spins the instant analysis starts, rather than showing four idle grey rings for a minute; and a cached result skips the reveal animation entirely, because nothing was recomputed and pretending otherwise would be a small lie the interface told every time you revisited a letter.
Where it stands
It's a hackathon build, and this page says so plainly. The default build runs on a bundled corpus rather than a server — but it's worth being precise about what that means, because the app is precise about it in code:
/* api.js — the app's single door to backend data. Two implementations sit behind it: · MOCK (default) — src/lib/mockApi.js, served from the bundled corpus of real letters + real pipeline outputs. No backend, no Ollama, no network. · LIVE — the ConsultBridge FastAPI backend at VITE_API_BASE. Flip with VITE_USE_MOCK in app/.env (`false` → live backend). */export const USE_MOCK = String(import.meta.env.VITE_USE_MOCK ?? 'true') !== 'false'
One door, two implementations, one environment variable. And the corpus behind the mock
is the real thing: 31 mtsamples letters carrying 27 medication changes and 62
follow-up tasks, generated by actual mistral-nemo:12b runs, with every
evidence phrase re-derived through the backend's own grounding logic so it is a real
substring of its own letter. That's why every value in the shelf's live-looking result
came out of the model itself — and why the demo survives a hotel network.
The privacy story deserves the same precision. The team designed for local inference — Ontario's PHIPA is the reason, Ollama on a clinic-owned GPU is the mechanism, and the README argues it well. But the shipped toolbar reads Operating · Cloud processing, with the endpoint showing Railway · OpenRouter: for the demo it ran on the cloud fallback. The pill tells the truth about which one you're on, which is the right call — but "no patient data leaves the clinic" is a promise the architecture makes and the demo has yet to keep.
Beyond that: the app is desktop-only, and this page defends that as a decision — squeezing a three-pane reconciliation surface to phone width gives you a different product, so the rail above scrolls sideways instead of pretending. The app lives in the repo. And nothing here has been near a real patient or a real physician's EMR; every result the app produces carries the line "AI-assisted draft — physician review required before clinical use" in its metadata, which is the only claim any of this is entitled to make.
Source: github.com/Adithiyan/ConsultBridge
— a team build from the Hackers & Healers AI in Healthcare Co-Design Hackathon,
June 2026. The pipeline (FastAPI · Ollama · mistral-nemo:12b) is my
teammates' work. Every value in the pieces above is transcribed from the shipped app
source, showing letter #900 of its bundled corpus; patients and physicians in that
corpus are fictional, and the DINs are real Health Canada records.