Inspiration
Every family I know keeps its medical history in a drawer. Lab reports from one lab, prescriptions from three doctors, a discharge summary from the year before. Each document is legible on its own. Nobody ever reads across them.
That gap is where the useful information hides. A blood pressure medicine prescribed by a cardiologist under one brand name, and again by a physician under a different brand name, is invisible to both doctors — and to the patient, who is taking two tablets that contain the same molecule. A lab value that drifts across four years never triggers anything, because every individual report comes back inside the normal range and gets filed away as fine.
I wanted to build the thing that reads across the drawer.
What it does
Kin is a family health record that reads itself.
Upload a lab report, a prescription, an imaging report or a discharge summary — as a PDF or a phone photo — and an agent works out what the document is, reads what is printed on it, decides which family member it belongs to, and files it into a shared timeline. Then it does the part that matters: it looks across every document in the record.
In the sample set it finds:
- Telmisartan prescribed twice, as Telma by one doctor and Telmikind by another, months apart. Two brand names, one molecule, and nobody cross-checked.
- HbA1c drifting 5.0 → 5.6 over 3.9 years, where every single reading is inside the reference range printed on its own report. No individual result is abnormal. The trend only exists between them.
- A dose titration (levothyroxine 50 → 75 mcg) correctly read as a change rather than as a duplicate.
- A prescription addressed to an ambiguous "R. Sharma" that it refuses to file, because two people in the family match.
There is also a chat that explains the record in plain language. It is deliberately an interpreter, not an advisor: it will tell you what HbA1c measures and quote what the radiologist actually wrote, and it declines when asked what a result means for you or what you should do about it.
How I built it
An npm workspaces monorepo in TypeScript, deployed as three Cloud Run services.
The rule that shaped everything: the model reads, the code computes.
Gemini returns only what is physically printed on the page — test name, number,
unit, and a bounding box. It is never asked to convert a unit, resolve a brand
name, decide whether something is trending, or do arithmetic of any kind. All
of that lives in @kin/shared: a package with no I/O, no network and no model,
covered by 23 tests that run in milliseconds.
That boundary is what makes the system checkable. A model having an off day can produce a bad reading, but it cannot invent a lab value that the deterministic layer then treats as real.
@kin/shared— the dictionary (33 canonical tests with synonyms and unit conversions, 52 drug molecules mapped from ~200 Indian brand names), trend fitting, duplicate and dose-change detection, and member attribution.@kin/ai— Genkit flows, every one with a Zod output schema: classification, four extraction shapes, follow-up derivation, visit briefs, and the chat.@kin/worker— the Pub/Sub push consumer.read → classify → extract → attribute → link, with every step timed and written to the document so the UI can stream the agent's progress and you can audit afterwards what it did.@kin/api— Express, Firebase ID token exchanged for an app JWT. It never touches document bytes; the browser uploads straight to Cloud Storage with a short-lived signed URL, so a medical document never lands in a request log.@kin/web— React and Vite.
Google Cloud: Cloud Run (3 services), Pub/Sub (OIDC push with a dead-letter topic), Cloud Storage, Secret Manager, Cloud Scheduler, Cloud Build. Model: Gemini 3.5 Flash for extraction and reasoning, Flash-Lite for classification, through Genkit.
A trend is only reported when an ordinary least-squares fit is actually supported by the points:
$$v(t) = \beta_1 t + \beta_0, \qquad R^2 = 1 - \frac{\sum_i (v_i - \hat{v}_i)^2}{\sum_i (v_i - \bar{v})^2}$$
Below an $R^2$ threshold, or with fewer than three results, it says nothing. Silence is preferable to a line drawn through noise in a medical record.
The second rule: low confidence stops the pipeline. When the agent cannot confidently decide whose document it is holding, it does not pick the closest match. It writes a review item and asks a human. In a medical record, a confident wrong answer is worse than no answer.
Challenges I ran into
A duplicate detector that silently did nothing. The guard against double-reporting was keyed on the resolved drug ID — but Telma and Telmikind resolve to the same drug ID, which is the entire point of the finding. So the one case it existed to catch was the one case it suppressed. Worse, my test passed: the fixture had encoded the same wrong world as the bug, giving the two brands different IDs. Re-keying on the brand as written fixed it. The lesson that stuck is that a test only proves your code agrees with your assumptions.
False precision. A creatinine of 97 µmol/L converted cleanly to 1.097264 mg/dL — six decimal places of confidence manufactured out of two significant figures. Converted values are now rounded back to the precision of their source.
Reference ranges silently left unconverted. SI-unit reports had their values converted but their printed normal ranges stored raw, which quietly made in-range results look out of range.
A retry loop that never retried. The database connection cached its connect promise. Once that promise rejected, every subsequent "retry" awaited the same dead promise without ever opening a socket — 33 attempts, zero connections, identical error each time, recovery impossible. One transient blip became permanent. Clearing the cached promise before each attempt fixed it.
Deploys that silently disarmed the pipeline. gcloud pubsub subscriptions
update --push-endpoint rewrites the whole push config, so it dropped the OIDC
token my create path had set correctly. Pub/Sub then pushed anonymously to a
worker that only accepts authenticated calls: 403, five retries, dead-letter
queue — and nothing whatsoever in the worker's log, because the requests never
reached the container. It worked on the first deploy and broke on every one
after.
An error message that lied. The MongoDB driver appends "your IP isn't whitelisted" to every server-selection failure against an Atlas host, regardless of the real cause. I spent real time fixing an allowlist that was never closed. I now unpack the driver's per-server topology errors instead of trusting the headline.
A 20-requests-per-day free tier. This was the most productive constraint of the project. It forced cheap classification on Flash-Lite before expensive extraction, content-hash deduplication so the same document is never read twice, and idempotent processing keyed on message ID — which is what makes at-least-once Pub/Sub delivery safe in the first place.
What I learned
- Draw the line between what the model does and what the code does, and put the arithmetic on the code side. It is the difference between a demo and something you could let near a real record.
- A passing test proves your code agrees with your assumptions. When both are wrong, the test defends the bug.
- Retry logic has to reset the state it is retrying, or it is a loop that re-reads a cached failure.
- Idempotency is not optional with at-least-once delivery — it is what lets you retry fearlessly.
- Refusal is a feature. The review queue and the chat's declines are two of the most valuable things in the product.
What's next
Rendering the bounding boxes the model already returns as highlights on the original scan, so every extracted value can be traced back to the pixel it came from. Gmail intake, so lab reports file themselves on arrival. Email and in-app notifications when a document finishes processing. And a mobile app — the API was designed for it from the first commit.
Built With
- cloud-build
- cloud-run
- cloud-scheduler
- cloud-storage
- docker
- express.js
- firebase
- firebase-authentication
- gemini
- genkit
- google-cloud
- mongodb
- mongodb-atlas
- mongoose
- node.js
- pub-sub
- react
- react-router
- recharts
- secret-manager
- tailwind
- typescript
- vite
- zod
Log in or sign up for Devpost to join the conversation.