I build apps for a living, and I had never built one where the wording of a sentence mattered this much.
Most people who wonder whether they are anxious or depressed never fill in a screening questionnaire. The questionnaires exist, they are free, and they take five minutes. But a form full of checkboxes feels cold, and it is easy to give up halfway.
So we tried something else: the same standardized questionnaires, asked out loud, one question at a time, like a short conversation. That became Mind Screen, an Android app built with Flutter. You pick a questionnaire, an assistant reads each question aloud, you answer in your own words, and a few minutes later you get a score and a plain-language explanation.
One thing first, because it shaped every decision below: Mind Screen is a screening tool, not a diagnostic tool. It is for adults 18 and over, and a result is something to discuss with a professional, not a verdict.
This post is about the engineering: what we got wrong in the first version, and the split of responsibilities we ended up with.
The constraint that shaped everything: the wording is fixed
A validated questionnaire such as the PHQ-9 is only valid if its questions are asked as written. "How often have you been bothered by feeling down, depressed, or hopeless?" cannot become "So, have you been feeling a bit low lately?", even if the second version sounds friendlier.
Language models love to rephrase. In our first version, the model ran the whole conversation: it chose the next question, phrased it, and kept track of where we were. It worked in demos. In real sessions it repeated a question it had already asked, skipped another, and in the worst case we logged, in April, it asked question 5 again while its own state already said question 6. On a 20-item questionnaire, that is not a small bug: the result is no longer the questionnaire.
The fix was to take the script away from the model. Each of the 11 questionnaires is now a JSON file shipped with the app, and every question is stored word for word:
{
"id": "q2",
"kind": "question",
"score_keys": ["q2"],
"text": "Over the last two weeks, how often have you been bothered by feeling down, depressed, or hopeless",
"options": "not at all, several days, more than half the days, or nearly every day?"
}
The app reads the question aloud and the app decides which question comes next. The model is told which item is being asked, but it never gets to say it, so there is nothing for it to "improve".
Who does what: app, model, code
We ended up with three roles, and most of our bugs went away once we stopped mixing them.
The app owns the script. It holds the current question index and moves it forward. Linear questionnaires simply go to the next item; a few longer ones have skip rules, and those rules are plain Dart functions, not prompt instructions.
The model interprets. Its only job on each turn is to map what the person said to one of the standard options. "Most days, I guess" becomes "more than half the days". It answers with a small JSON object:
{
"scores": { "q2": 2 },
"answer_status": "scored",
"acknowledgment": "Thank you, that helps.",
"crisis_flag": false,
"test_status": "in_progress"
}
If the answer is ambiguous ("it depends on the week"), the model says so with answer_status: "ambiguous", and the app asks the same question again instead of guessing. After a few unclear attempts the app moves on rather than trapping the person in a loop; we would rather have one weaker item than an exhausted user.
Plain code keeps the score. Totals, sub-scales and severity levels are computed in Dart from the per-item values, with fixed rules. The model never adds anything up.
The turn logic reads roughly like this (simplified):
final decision = ScriptNavigator.decide(
currentIndex: answeredIndex,
totalItems: script.totalItems,
currentItemScored: scoredOk,
llmTestStatus: llmStatus,
);
switch (decision.action) {
case TurnAction.reask: // ambiguous answer: same question again
case TurnAction.advance: // app moves the index, reads the next item verbatim
case TurnAction.complete: // app computes the result
case TurnAction.crisis: // stop and show help resources
case TurnAction.abort: // the person wants to stop
}
We also route by questionnaire. Short questionnaires with simple scoring (PHQ-9, GAD-7) use a small model; longer ones, or those with sub-scales and conditional rules, use a slightly larger one. Most run at temperature 0, because we want the same answer to map to the same option every time:
'01': LlmRoute(model: 'gpt-4.1-nano', maxTokens: 800, temperature: 0.0), // PHQ-9
'02': LlmRoute(model: 'gpt-4.1-nano', maxTokens: 800, temperature: 0.0), // GAD-7
'11': LlmRoute(model: 'gpt-4.1-mini', maxTokens: 800, temperature: 0.0), // PCL-5
A note written the day before publishing: OpenAI shut down gpt-4.1-nano on 23 October 2026. From then on, our PHQ-9 and GAD-7 sessions failed on the very first turn with a 404 from the provider, and we only noticed three days later, because nobody had run those two questionnaires in between. Every call goes through our own server function (more on that below), so the fix went there, within hours: the function now swaps the retired model for gpt-4.1-mini before calling the provider, and installed copies of the app recovered without a store update. The table above will catch up in the next release.
If I had to sum up the architecture in one line: the AI listens, it never keeps the score.
Safety: what happens on a hard answer
Some questionnaires touch on very heavy topics; item 9 of the PHQ-9 asks about thoughts of self-harm. When an answer suggests a crisis, the model sets crisis_flag. The app then stops the questionnaire and shows crisis resources for the person's country (we only list helplines we have verified, and fall back to an international directory otherwise).
We want to be honest about the limit here: this is a signal from a language model, so it is imperfect. That is why the help message is broad, why it never depends on the final score, and why the app keeps repeating what it is and is not.
The voice pipeline
Listening. For most questionnaires we use the phone's native speech recognition. We debounce the end of an answer (people pause in the middle of a sentence, especially when the question is personal), and after repeated silences the app checks in ("Are you still there? Take your time…") and listens again, instead of waiting forever or moving on without an answer. For the cognitive screening, where the exact words matter (recalling words, for example), we record the answer and transcribe it with Whisper instead.
Taking turns. The microphone is off while the app is speaking. Otherwise the app hears itself, transcribes its own question, and answers it.
Speaking. The questions are known in advance, which is a gift for latency. We fetch the audio for the next few questions while the person is still answering, cache it on the device, and play the short acknowledgment and the next question as two separate clips, so the acknowledgment can start immediately. On Android we use a compact audio format to keep each clip small on slow mobile connections.
One voice per session. This one surprised us. When a network error made a single sentence fall back to the phone's built-in voice, it felt like another person had joined the conversation. A slow voice is annoying; a voice that changes breaks trust. The app now picks one voice engine at the start of a session and never mixes engines until the session ends.
No AI keys in the app
Every call to the model, to speech-to-text and to text-to-speech goes through a Supabase Edge Function. The app sends the user's Firebase ID token; the function verifies it, applies per-user daily limits, and only then calls the provider with a key that lives on the server. There is no provider key in the APK to extract.
A valid sign-in is not a blank cheque either. The function rebuilds every request from an allow-list (which models, how many output tokens, which voices, how large a payload) instead of forwarding what the app sent, so a token copied out of the app cannot be used to call a more expensive model. That same layer is what let us swap a retired model within hours of noticing it.
The same function writes a small usage log per user (which kind of request, which questionnaire was started or completed, never the answers), which is how we enforce limits and see where people drop off.
Results stay on the phone
Results, history and PDF reports are stored locally in SQLite, and the PDF is generated on the device. Android backups are disabled for this data, so it does not silently end up in a cloud backup. Users can delete their account from inside the app.
To be precise about what does leave the phone: each answer goes, through the same server function, to the model provider so it can be matched to an option. For most questionnaires that is the text of the answer; for the cognitive screening it is the recording, which is transcribed first. We do not keep it on our side: the result, and the log of answers behind it, exist only on the device.
The trade-off is real and we say it plainly: without a server copy, changing phones means losing your history. For health data, we think that is the right default.
What we would tell another team
- Put the part that must be exact in code, not in the prompt. Every rule we wrote as a prompt instruction ("never skip a question", "always repeat the wording") eventually failed. The same rules in Dart have not.
- Test on real mid-range phones. Microphone focus, audio latency and speech recognition behave very differently on a budget Android device than on a recent flagship or an emulator.
- A voice that changes breaks trust faster than a slow voice. Consistency matters more than polish.
- A health app on Google Play needs an Organization developer account. If your app declares health features, plan for a D-U-N-S number and the account verification well before your launch date.
- Registered testers are not users. A closed test can have plenty of opted-in testers and almost no completed sessions. Measure starts and completions, not sign-ups.
- Keep the model name on the server. Providers retire models on their schedule, not yours, and a store review takes days. Every other model we use has a shutdown date between January and February 2027; the ones we can switch on the server will not need a release.
Current limits: English only, Android first, and a screening tool only.
How would you handle fixed wording in your own voice app? I would love to hear how others deal with it in the comments.
Mind Screen is available for Android: mind-screen.com. Our public profile is on GitHub: github.com/mind-screen. It is a self-awareness aid, not a diagnostic tool, and it does not replace the opinion of a qualified mental health professional.



Top comments (0)