DEV Community

Pedro “PT” Thomaz
Pedro “PT” Thomaz

Posted on

Why I shipped rules and statistics instead of an LLM for my health app

I build Vitra, a desktop app that reads your Oura Ring data and gives you one plain sentence each morning about how your body is doing. The obvious way to build that in 2026 is to pipe the numbers into an LLM. I went a different way, and I think the reasoning generalises past health apps.

The problem with the LLM version

Exporting a month of JSON into a chat window every morning is a chore, but that is a UX problem and UX problems are solvable.

The harder problem is stability. Push back on a recommendation and the model tends to fold and tell you the opposite. Same numbers, same night of sleep, different advice, because you argued.

That is fatal for this particular product. If the app says take a rest day, the entire value is that it holds that position when you do not want to hear it. An opinion you can talk the model out of in one message is a mirror with extra steps.

This is sycophancy. It is well documented, and it is not something you fix with a system prompt telling the model to be firm. You can reduce it. You cannot make it deterministic.

What I shipped instead

Transparent rules and statistics computed over the user's own history:

  • Establish a personal baseline per metric over the first one to two weeks, rather than comparing against a population average
  • Detect deviations from that baseline, not from a global "normal"
  • Require multi-day agreement before making a confident call, so one bad night does not trigger anything
  • Encode the decision thresholds explicitly, before the app has seen today's data

That last point is the one that matters. The rule is committed to in advance, today's numbers get evaluated against it, and the user arguing does not change the arithmetic.

What that buys you

Determinism. Same inputs, same output, every time. You can write tests. With a model in that position, "did it give the right answer" is a vibes check.

Explainability. When it says recovery is trending down it can point at which signals moved and by how much. That is the actual computation, not a post-hoc rationalisation.

Regulatory headroom. Under EU MDR Rule 11, software intended to inform a diagnostic or therapeutic decision starts at Class IIa. A wellness app has to stay clearly on one side of that line, and it is much easier to argue about a rule you can print out than about a model's output distribution.

It runs locally. No API calls means no server, no account, no telemetry, and health data that never leaves the machine. This was the deciding factor. Local-first with a frontier model in the loop was not going to happen, and the privacy story turned out to be what people actually cared about.

The part I underrated: you can correct it

A fixed rule is a thing you can teach. If Vitra blames your late workout for a bad night and that is wrong for you, you say so, and the correction sticks. Your thresholds drift toward you over time instead of sitting at the average of everyone.

Determinism is what makes that possible. Something that rerolls its own reasoning every morning has nothing stable to correct, so there is nothing to teach.

Worth being precise about what determinism means here. Same inputs give the same output, and your corrections are part of the inputs. The sentence can change over time, and when it does you can point at exactly which correction moved it.

Where a language model still earns its place

I am not arguing against LLMs. I am arguing against putting one on the critical path of a decision that has to be stable.

Phrasing is a good use. Turning a computed result into a sentence that sounds human is exactly what these models are good at, and if that layer is sycophantic the worst case is slightly awkward wording. Freeform Q&A over your own history is another. Both sit downstream of the judgement rather than being the judgement.

The heuristic I ended up with: if you would be upset by the system changing its answer because the user pushed back, do not put a language model in that position.

Caveats

Rules do not generalise for free. Every new signal is real work, and a threshold that looks sensible on paper can turn out to be noise once it meets real data. A model would have "handled" those cases immediately, just not necessarily correctly, and you would not know which ones.

There is also a real product cost. The app stays deliberately quiet for the first couple of weeks while it learns your baseline, which is a strange thing to ship when every competitor tries to impress you on day one. I still think it is the right call.

Vitra is at https://vitrahealth.app if you want to see what it turned into. Mac and Windows, one-time purchase, no subscription.

Happy to be argued with, especially by anyone who has made the sycophancy problem tractable in production.

Top comments (1)

Collapse
 
amitfeldman profile image
Amit Feldman

Nice writeup — the "rules beat an LLM when the domain is well-understood" argument is one more health-app builders should hear.

I ran a quick check on vitrahealth.app and found a few things worth a look:

  • 5 of 6 standard security headers are missing — Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy. HSTS is set (2 years, good), so the foundation is there. For a product handling Oura health data, these are cheap trust signals: CSP and frame-ancestors are the two that matter most, and both are single-header additions on most static hosts.
  • Clickjacking is currently possible — without X-Frame-Options or CSP frame-ancestors, the site can be iframed by anyone. For a login/checkout flow later, that becomes a real risk rather than a theoretical one.
  • Meta description is 500 chars — Google truncates around 155–160, so your search snippet cuts off mid-sentence. The first sentence alone ("Vitra Health is a desktop companion for your Oura Ring...") is a strong description if it stands alone.
  • Small one: TLS cert expires in ~50 days. Presumably auto-renewed, but worth confirming the cron/systemd timer actually fires — expired certs are the most common "site is down" cause I see on small launches.

None of these block a launch, but the header layer is maybe 20 minutes of config and it's the first thing anyone evaluating a health-data product checks. Happy to re-run the check once you've tweaked anything.