Building working memory into an AI companion, letting the model author its own UX signals, and why a 429 error in a journaling app is an emotional rupture — not a technical inconvenience.
Today, my AI journaling companion app crossed a critical threshold. In a single 11-chat marathon, the project moved from "testing gate incomplete" to functionally ready for real-world users. That transition wasn't about shipping features—it was about closing the gap between a system that works in isolation and one that holds up under the entropy of real sessions, real browsers, and real emotional stakes.
Here's what I shipped, what broke, and what I learned about building AI products where failure isn't just a bad UX—it's a broken promise.
The hardest design constraint in an AI companion app is that safety can never be optional, but it also can never be blocking. These goals are in direct tension.
I built a metadata-only crisis_events table—no message content, just structured signals—supporting INSERT, SELECT, and DELETE to satisfy CCPA's right-to-deletion requirements. The critical decision was wrapping the entire logging path in a try/catch so that a database failure never prevents the user from receiving their safe harbor response. The mental model: safety instrumentation is observability, not control flow. If your crisis detection layer can take down the conversation, you've introduced a failure mode exactly where your user is most vulnerable.
This is a pattern I'd generalize broadly: in any system where the user's emotional or physical state is at risk, your telemetry must be strictly decoupled from your response path.
Most AI product teams think of context as a model-layer problem. I've found it's equally a product architecture problem.
Luna now dynamically assembles a [WORKING MEMORY] block before every conversation—user name, total session count, streak data, and the last 10 journal summaries. This isn't RAG in the traditional sense; it's a structured prompt preamble that gives the model just enough state to behave like a continuous relationship rather than a stateless endpoint. Failures in any individual fetch return an empty string, so the conversation always proceeds—just with gracefully reduced personalization.
The trade-off here is deliberate: I'm paying a token cost on every request to avoid the uncanny valley of an AI that forgets you exist between sessions. For a journaling product, continuity is the product.
This extends into the greeting layer. Hardcoded hellos were replaced with dynamic greetings that distinguish returning users, active streaks, and first-time visitors with a dedicated welcome flow. Small surface area, outsized impact on perceived intelligence.
One of the more consequential decisions today was moving interaction hinting from the frontend to the model. Luna now emits machine-readable [CHIPS: "label"] tags at emotionally significant moments, which the Edge Function parses into tappable prompt pills on the frontend.
The alternative—frontend heuristics that guess when to surface prompts—would have been faster to build and catastrophically worse in practice. Emotional salience isn't something you can regex out of a conversation. By letting the model signal when a moment matters, the interaction suggestions arrive with the right timing and the right framing. The frontend becomes a renderer, not an interpreter.
This is a mental model I keep returning to: in AI-native products, push intelligence upstream. The model should be the author of interaction patterns, not just the responder.
A 429 error in a journaling app isn't a technical inconvenience—it's an emotional rupture. You've asked someone to be vulnerable, and then you've slammed a door in their face.
The fix was structural, not cosmetic. The Edge Function now returns a message_count with every response, powering a frontend counter that lets the UI transition gracefully. Instead of an error toast, the user sees a warm "That's a wrap for tonight" message. Same constraint, completely different emotional register.
The lesson: rate limits in emotionally sensitive products are a design surface, not an error state. If you're treating them as exceptions, you're designing for your infrastructure instead of your user.
Working as a solo founder with AI-assisted development, I hit a wall today that I suspect every serious AI-augmented builder will encounter: the context window is a project management constraint, not just a technical one.
My canonical project instructions file—the "golden master"—had grown too large for a single context window to hold productively. The solution was splitting it into two artifacts: an Active Golden Master containing current state and queued work, and a Static Reference containing architecture conventions and the Luna personality spec. Mid-day chats simply append change blocks to the bottom of the Active file; a clean reconciliation happens only at end-of-day.
Combined with a strict "one clear deliverable per chat" discipline, this cut load times and dramatically improved context retention across sessions. The meta-lesson: when you're vibe coding with AI, your document architecture is your sprint architecture. Treat it with the same rigor you'd give a backlog.
The bugs that consumed the most time today all lived at integration boundaries—the seams between systems that each work correctly in isolation.
The adversarial test gap. My adversarial testing script kept triggering Luna's first-session welcome because it was sending messages without session history. The model was behaving correctly—it just had no evidence the user had ever been seen before. The fix was fetching and prepending a returning-user context before each test run. The lesson: adversarial tests that don't replicate production state aren't adversarial—they're fictional.
The auth flicker. Supabase's onAuthStateChange fires on background token refreshes, briefly setting the session to null. In a standard SaaS app, this causes a momentary flicker. In a journaling app mid-entry, it unmounts the entire conversation. The fix was restricting session clearing to explicit sign-outs only. The lesson: auth lifecycle events that are invisible in most products become experience-breaking in high-continuity interfaces.
The phantom re-render. Tab-switching caused onAuthStateChange to emit a new session object reference—same session, new object—which React interpreted as a state change, triggering a full re-render. The fix required two interventions: comparing access_token values instead of object references, and globally disabling React Query's refetchOnWindowFocus. The lesson: reference equality bugs are the silent killers of real-time applications.
The browser-as-adversary. A persistent "network error" on voice input turned out to be Brave browser blocking outbound connections to Google's speech recognition servers. No error message, no console warning—just a silent failure that looked identical to a network issue. Confirmed by testing in Edge. The lesson: when your feature depends on a browser API that phones home to a third party, your compatibility matrix just tripled.
With the app functionally ready, I built a complete user testing package: outreach messaging, an OAuth setup checklist, an analysis guide, and a strictly scoped 7-question Google Form. The form constraint was intentional—every additional question reduces completion rates and dilutes signal quality. When you're a solo founder with limited testers, respecting their time isn't just courtesy; it's methodology.
Three principles crystallized this today that I expect to carry forward:
Safety is observability, not control flow. Instrument everything. Block nothing. Your crisis detection layer should never become the reason a crisis response fails to reach the user.
Push intelligence upstream. In AI-native products, the model should author the interaction patterns, not just respond within them. Frontend heuristics are a poor substitute for model-driven UX signals.
Document architecture is sprint architecture. When building with AI assistants, how you structure your reference documents directly determines your development velocity and context quality. Treat your golden master like a codebase—it needs refactoring too.
The app is ready for testers. The real learning starts now.