The Reframer
You give it a hard moment, in your own words if the list does not have it, and the emotions attached to it. It gives back a short piece of fiction with your moment buried inside it, illustrated, in one of eleven languages, at a reading level you set. It is careful to say it is a reflective tool and not therapy. The engineering interest is narrower: two model calls where only one is allowed to fail quietly, and a hard boundary between what the model invents and what the schema will accept.
Two things make this harder than "call a model with a prompt". The first is that the input is someone's worst month, so the output has to arrive intact: a generation that half-fails and shows a stack trace is not a degraded experience here, it is an insulting one. The second is that free text goes in and structured data has to come out, in eleven languages, with downstream code that expects enumerated values rather than whatever adjective the model liked today.
So the interesting decisions are all about placing boundaries. Which call is allowed to fail. Which model output is trusted and which is checked against a list. What the interface does during the wait, given that the wait is not incidental to this product, it is most of the felt experience.
> the flows run as server actions inside the Next app: no separate AI service, no queue, no orchestration layer for two sequential calls.
A model was retired and production broke silently
Google withdrew the Gemini 2.0 line and the app's calls stopped working, with nothing in the codebase that could have warned about it: a model identifier is just a string, and a wrong one fails at request time, in production, in front of a user. The fix was not only the migration. It was a smoke command that exercises live model routing on demand, so a retired or mistyped identifier fails loudly on a machine rather than quietly on a stranger. The lesson got written down in the decision log as "verify model availability, don't assume".
The model suggests, an allow-list decides
Emotions are proposed by a model and then checked, one by one, against the enumerated list the rest of the app actually understands. Anything it invents is dropped without ceremony, and if nothing survives the user is simply asked to pick. It costs a genuinely good suggestion now and then. It buys a guarantee that no downstream schema ever receives a value it was not built for, which is the difference between a feature and an intermittent bug report.
Only one of the two calls is allowed to fail
Story and illustration are separate calls, deliberately, and only the story is load-bearing. The image runs afterwards in its own error boundary: if it fails the user gets a quiet note and keeps a complete, readable story. The cost is honest, total latency is the sum of two round trips rather than one, and the picture cannot stream in alongside the text. In exchange, an image model having a bad afternoon never takes the thing the user actually came for.
The wait is the product, so it was designed rather than hidden
Generation takes long enough that a spinner would be a wasted moment, and this is not an app where you want the user staring at nothing thinking about why they are here. The wait became a screen that moves from twilight to dawn while narrating the user's own answers back to them, then resolves once into the finished story. It is done entirely on the client with an animation frame loop and a CSS variable, which is the point: no streaming infrastructure, no server push, no extra failure mode, for something whose whole job is to be looked at.
Turning the bot check off was not enough
On a demo domain the bot-check key is not registered, and the widget renders a visible error badge complaining about the domain. Disabling the check still left the badge, because the provider was still mounted. So the provider is not mounted at all when the check is off, and the generation hook is written to work with no provider present. A branch in a page component and a fallback path in a hook, to remove an error message from the first thing a prospective client sees.
Accounts built, wired, and deliberately switched off
Sign-in and a saved shelf exist in full, with owner-only database rules, and are dormant: with no credentials configured the app substitutes harmless placeholders so server rendering cannot throw, and the shelf renders a resting state instead of an error. Turning it on is a configuration change rather than a code change. Worth saying plainly, since a demo that quietly pretends its account system is live is exactly the sort of claim this page exists to avoid.
Most of the work in a generative feature is not the prompt. It is deciding which call can fail without taking the product with it, where to put a validator between a model and your schema, and what the interface does for the seconds you cannot make shorter. That reasoning is what survives when the model underneath you gets retired, which it will. If you are putting a model in front of users and want the boring parts right, that is the conversation. Let's talk.