Mobile app / backend / AI surface
Ember
An Android app for rehab adherence that also exposes itself to AI assistants, with the one thing an assistant must not do prevented at the API rather than asked for in a prompt.
- Kotlin
- Jetpack Compose
- WorkManager
- Ktor
- Exposed ORM
- PostgreSQL
- Flyway
- Model Context Protocol
- JWT
- Google OAuth
- Docker
Overview
An app built for one specific job: keeping up a physiotherapy routine once the clinic visits stop, because the clinic was mostly supplying consistency and that is the part software can replace. It later grew a second face. As well as the phone, it exposes a Model Context Protocol server, so an assistant can be asked what today looks like or how the streak stands. That second face is where the interesting design decision lives.
Problem
A streak is trivially cheatable and a reminder is easy to get wrong, and both failures are quiet. If the client decides when a day is complete, any device clock can fabricate a run. If the reminder depends on a server, a push service or a tunnel being healthy, it fails on exactly the evening it was needed. Opening the app to an assistant adds a third: an assistant that can mark a workout done is an assistant that can quietly dismantle the only thing the app is for.
Solution
The server decides. Streaks are computed from recorded completions whose timestamps are validated rather than trusted, rejected if they are in the future or older than a grace window that exists so finishing offline at 23:50 and syncing after midnight still counts. Rest days need no stored row and bridge a run rather than breaking it. Schedules are versioned with an effective-from date and the timezone is stored on the row, so a later move cannot reinterpret history. The assistant surface reads all of that and can change a schedule, but the credential it holds is rejected outright by the endpoints that record a session, delete the account or change the timezone.
Architecture
A Kotlin and Ktor API over PostgreSQL with Flyway migrations, an Android client in Compose, and a separate protocol server for the assistant surface, all containerised. Reminders are local notifications driven by WorkManager rather than push, because the phone already knows the time and a backend would mean a per-user, per-timezone scheduler that fails whenever anything upstream is unhappy. User-created exercises are scoped by owner at the query layer, which is a security boundary rather than a filter: schedule slots have no notion of ownership, so without it one account could schedule another account’s private exercise and read the name back out of its own schedule. That was verified with two real accounts rather than reasoned about.
Key decisions
Put the limit in the API, not the prompt. An assistant is told it cannot record a workout, and separately the credential simply does not work on that endpoint, so the guarantee survives a model that ignores its instructions. Refuse rather than fail open: an empty allowlist on the consent path denies everyone, because the alternative admits every account. Mark user-created exercises as such in the picker and say plainly that they have no form video and came from no programme, because the shared catalogue has a physiotherapist behind it and something typed in on a Tuesday does not. And write down an accepted hole rather than hide it: a schedule of nothing but rest days accumulates a streak for doing nothing, which is what letting rest days count implies.
Challenges
The reminders had never fired. Not for anyone, not once, and every manual test had passed. Scheduling on application start used a policy that replaces any pending work under the same name; when a reminder fires and the app is not running, the system starts the process to run it, so startup ran first and cancelled the very job that had just woken it. A reminder could only survive if the app was already running, which is precisely when nobody needs one, and testing by hand always met that condition. The fix was one policy value. The lesson was in the tests: they covered the pure function that computes the next time and never exercised the policy at all. A test written alongside the fix turned out to be incapable of failing, because the API it asserted against does not return what the replace policy discards, so it was deleted. Separately, the release script reported success while nothing shipped, parsing a rejected commit cleanly enough to announce a version that the store was not serving; it now refuses to claim success unless the track actually serves the version just uploaded.
Outcome
On an internal distribution track with a small group of testers, backed by a suite of roughly 450 tests across the app, the API and the protocol server. The honest status is that nobody has recorded a session through it yet, including the person it was built for, and the reminder fix has been proven failing cold but not yet proven succeeding cold. Both are written down rather than rounded up. What it demonstrates is the shape of the reasoning: deciding what the server must own, what the device is better at, and which capability an AI client should simply never be given.