"What happened when I stopped designing my AI coach for flexibility and started designing it to be predictable enough to write down."
Last month I deleted three features from my AI app. Nobody asked me to.
I removed them because I couldn't explain them clearly. And I've started treating that as a hard signal: if I can't describe how a feature behaves in plain language a tired parent would understand, it isn't finished — it's a liability.
This post is about the engineering that came out of that rule. It's specific, it has code, and it's the part of building an AI product that nobody puts in the demo.
The context
I'm building FamNest, an AI wellness tool for parents. The user I design for is the one refreshing a feeding tracker at 3am, googling "is this normal" with one hand while holding a sleeping baby with the other.
That user changes your engineering priorities. Latency matters less than predictability. "Clever" is a risk, not a feature. And the moment your AI says something subtly wrong to someone in that state, you've lost them — and possibly done real harm.
So the question I kept returning to wasn't "what can the model do?" It was "what is the model never allowed to improvise?"
Problem 1: a coach with too many answers
In the first version, my AI coach could respond to the same question a dozen slightly different ways. It felt smart. Flexible, even.
It was actually a nightmare. When I sat down to write the documentation, I couldn't promise a parent what the system would do. And if I can't promise it, why should they trust it?
The fix was to put a second model in front of the user — a safety reviewer — and give it exactly three outcomes. Not a confidence score. Not a freeform critique. Three discrete verdicts.
``type Verdict = "ok" | "revise" | "crisis";
interface ReviewResult {
verdict: Verdict;
reason: string; // for logging, never shown to the user
revisedDraft?: string; // only present when verdict === "revise"
}``
The coach generates a draft. The reviewer judges it. The whole contract fits in four lines, and that's the point — I can document it in one sentence: the reviewer either approves the draft, rewrites it, or escalates to a crisis response.
``async function generateReply(userMessage: string): Promise {
const draft = await coach.respond(userMessage);
const review = await safetyReviewer.review(userMessage, draft);
switch (review.verdict) {
case "ok":
return draft;
case "revise":
return review.revisedDraft ?? draft;
case "crisis":
return CRISIS_RESPONSE; // see below — this is not generated
}
}// This string is reviewed by a human, version-controlled,
Problem 2: the crisis floor
Here's the decision I'm most sure about.
When a message hints at a crisis, the system does not generate a response. It returns fixed, human-reviewed text. Every time. Byte for byte.
// and documented word-for-word in the user-facing docs.
const CRISIS_RESPONSE = .trim();
It sounds like you're going through something really hard right now.
You don't have to handle this alone. If you're in immediate danger,
please contact your local emergency number...
async function handleMessage(userMessage: string): Promise {
This is the opposite of how we usually think about generative AI. The whole appeal of an LLM is that it composes something new. But the situation where a parent most needs the response to be right is exactly the situation where I least want the model to be creative.
I call this the crisis floor: a deterministic baseline that the system can never generate its way below. The model can make the experience better above the floor. It is never allowed to touch what happens at it.
A subtle but important detail: the floor is checked before the clever path, not after.
// Deterministic guardrail runs first, independent of the LLM.
if (crisisFloor.matches(userMessage)) {
return CRISIS_RESPONSE;
}
return generateReply(userMessage);
}async function coachRespond(message: string): Promise {
If the generative pipeline is down, malfunctioning, or hallucinating, the floor still holds. It doesn't depend on the part of the system most likely to fail.
Problem 3: what happens when the provider falls over
LLM APIs go down. Rate limits hit. A region has a bad day. For most apps that's an annoyance. For an app a parent leans on at 3am, a blank screen is a broken promise.
So every external dependency has a known fallback, and "the model is unavailable" is a documented state — not an exception that bubbles up to a stack trace.
try {
return await llm.complete(buildPrompt(message));
} catch (err) {
logger.warn("LLM provider unavailable, degrading gracefully", { err });
// A safe, generic, pre-written reply. Not an error.
return GRACEFUL_FALLBACK;
}
}``
The user gets a calm, honest message instead of a spinner that never resolves. Graceful degradation isn't a nice-to-have here. It's part of the trust contract.
The thing I actually learned
The pattern underneath all three: documentation wasn't something I wrote after the engineering. It was the engineering.
The doc became the spec. When a behavior couldn't be written down cleanly — three verdicts, fixed crisis text, a named fallback state — that was the signal the design was wrong, not the writing.
It turns out "predictable enough to document" is a great design constraint. It pushes you toward small, discrete contracts and away from the kind of open-ended cleverness that demos well and ships badly.
I think a lot of AI products are shipping behavior they can't fully explain and quietly calling that confusion "intelligence." For the users I build for, clarity is the feature.
If you can't document it, you don't understand it yet.
I write about building and documenting production AI systems. If you've shipped a guardrail or fallback you're proud of — or one that bit you — I'd genuinely like to hear about it in .
Top comments (0)