all builds >> N. GUGLIELMI
>> nicola@guglielmi ~ / labs / reframer

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.

Next.js 16 React 19 + Compiler Genkit Gemini 3.5 Flash Gemini 2.5 Flash Image Zod structured output Cloud Run
>> THE_PROBLEM

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.

>> FROM_A_MOMENT_TO_A_STORY
Four-step wizardevent, emotions,style, length, language Suggest emotionsmodel proposesup to five Allow-list filteranything off-list dropped Bot checkscore thresholdbefore any spend Storyschema-constrainedtitle + body Illustrationseparate call, may failstory survives either way Read, listen, keepword-by-word revealspeech synthesis, PDF phase 1 phase 2, optional The model is trusted to write prose. It is not trusted to name an emotion the rest of the code has to switch on.

> the flows run as server actions inside the Next app: no separate AI service, no queue, no orchestration layer for two sequential calls.

>> IN_ACTION
The Reframer's composer: a four-step indicator reading The moment, How it feels, The telling and Create, with a life-event selector and a free-text field for describing your own, under the headline Reframe the moment
> step one of four, on the live app. the list of life events exists so the common cases are one tap, and the free-text field exists because the list will never be complete. a disclaimer, dismissed to take this shot, states before anything else that this is not therapy.
>> DECISIONS_THAT_MATTERED
[ 01 ]

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".

[ 02 ]

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.

[ 03 ]

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.

[ 04 ]

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.

[ 05 ]

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.

[ 06 ]

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.

>> WHY_THIS_MATTERS_TO_YOU

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.