Turning product data into user understanding, without the hallucinations.
Problem
I've seen this pattern across a lot of product teams. They do the research, but the rest of the team ends up ignoring the results. Not because they don't want to use it, but because they can't even connect what they found with the decisions in front of them. The persona ends up in a Figma file or a PPT. The interview transcripts live in a Notion doc that not even the person who did the interview goes back to revisit.
I realized the gap wasn't effort, it was how that data reached the team. So I built Resense: to help product teams get value from what they already had, without needing a specialist to interpret it or spending hours on a persona artifact that ends up in the bottom drawer.
The AI stack
The generation pipeline maps raw product data through JTBD and anti-optimism guardrails, structuring it into Synthetic Users via OpenRouter with Claude Sonnet in production and Gemini Flash in development. I found the two-model strategy through testing: dev iteration needed speed, production needed reasoning quality for complex synthesis. They serve different jobs.
RAG-powered evaluation retrieves relevant source chunks to ground pattern analysis in actual user data. This is what lets non-specialist product teams trust the output: every insight traces back to a source.
Prompt caching does two things: it reduces token cost and cuts response latency by 63%. On a research tool where people are waiting for a synthesis before a meeting, that speed matters as much as the content itself.
Every request goes through a BYOK layer for the free tier: users bring their own API key (OpenRouter, Anthropic, OpenAI, or Google), which never leaves the browser. Auto-detection routes keys to the right provider. The server never stores or logs guest keys.
The design decisions
Calibrating trust was the hardest design problem. LLMs feel authoritative even when they're guessing, so the biggest risk in the product was delivering outputs that teams used without questioning. I landed on three systems that address this directly.
Source citations per attribute: every insight in a Synthetic User profile links back to the source it came from. Inferred fields are tagged [Inferred] in the output. Users see exactly what came from their data and what was model inference.
The anti-optimism guardrail is an explicit prohibition list in the system prompt: four patterns that make a generation invalid. The layer forces the model to describe friction points as specific situations, when, where, what happens. If a friction point is too vague, the generation fails validation.
The triangulation score measures source diversity (behavioral, attitudinal, contextual) with a multiplier: one source type yields 0.6x confidence, three types yield 1.0x. The ConfidenceBadge component renders this as a warning, informational, or confirmation state, and shows exactly which source types are missing.
Evaluation and improvements are off the critical path, triggered on-demand. The model call for profiles closes, then users can trigger a separate call for patterns and recommendations when they need it. Two seconds on demand keeps the main loop fast, which is where the team spends most of their time.
The failure mode
When the model doesn't have enough data to generate a reliable Synthetic User, it says so. I built a DataHint panel that surfaces three signals: an analysis of what's missing, the specific data types needed, and a suggested research question to go get them. The panel uses progressive disclosure. The analysis is visible by default. The gaps and suggested question expand on interaction.
I decided that showing a low-quality Synthetic User with no signals of uncertainty was more dangerous than being honest about insufficient data. Teams that trust bad research make worse decisions, and avoiding that was exactly the point.
Before any user data reaches the LLM, an anonymizer strips PII: emails, Brazilian CPF numbers, phone numbers, IP addresses, and names in context. The model receives only anonymized text. Data at rest uses field-level encryption. I have no access to raw user content. That costs me admin convenience. It was worth it.
How I work with AI
Resense didn't start as a product. It started as my own process. Synthetic Users are how I get a first read on who I'm designing for in hours instead of weeks, then I validate the ones that matter with real people. A starting point, not a replacement for research.
The same instinct runs through the rest of my work. I prototype navigable flows in Claude Code and Cursor, so engineering gets something they can click through instead of a static screen, which is where handoffs usually break. And I built a design agent that carries my design principles and a set of Synthetic Users as context, running concept and usability checks against them before I sit down with a real user. When I compared its verdicts to real sessions, they landed close enough to trust as a first pass.
AI isn't a step I bolt onto the end. It's in the research, the prototype, and the test, so I spend my time on the decisions instead of the setup.
What's next
I want Synthetic Users that evolve on their own as new product data comes in, without manual regeneration. And a research agent that learns from correction signals and suggests what to validate next based on the gaps it already flagged. The goal is a system that gets more useful the more the team uses it.
Saving links doesn't make you smarter. Learning from them does.
Problem
I was that person with hundreds of saved links I'd never open again. I saved articles, videos, references. But when I needed something, I'd end up searching for a title I barely remembered, in folders I barely organized. The tool I used organized what I saved. The problem was it didn't help me learn from it or find it when I actually needed it.
So I built Coisas.space around one question: what if the content you saved could surface itself when you need it, without you having to remember it existed?
The AI stack
Categorization runs on gpt-4o-mini via OpenRouter at temperature 0.2. I found that temperature through testing: higher temperatures introduced category drift, the same link categorized differently on repeat saves. 0.2 gives the consistency that a categorization feature needs to be trusted.
The conversational retrieval layer uses the category as a scoped context signal, a RAG-like approach fast enough for consumer UX and accurate enough to be useful. I tested with full document embedding and it was slower than the actual query patterns justified.
When context is thin, retrieval falls back through three levels: visible content from the active category (last 3 days), then all content from that category, then all content regardless of category. The fallback strategy keeps responses grounded even for users who just started.
The Foco Time matching algorithm uses semantic AI, so "Learn React" surfaces links about hooks, components, and JSX, including links that never mention React in the title. Results are cached in KV with hash-based invalidation: when a new link is saved, the hash changes, cache invalidates, and the next match request regenerates.
The design decisions
Streaming was non-negotiable for me. Any categorization that required waiting would break the save habit. The product only works if saving feels instant. The link appears immediately in the grid with a pending state, categorization completes in the background, status progresses from pending to processed to read.
I scoped the conversational interface by category on purpose. Open-ended chat over all saved content produced irrelevant answers in testing, so narrowing the context was the decision that made the feature work. When users switch categories while a chat is open, an inline warning appears with two options: keep the current context or re-ground the conversation in the new category.
The /resumir command uses Fibonacci-based progressive disclosure: the first call returns 5 links. Users can ask for more, and the sequence expands to 8, 13, 21, 34. Users discover what they have at the pace they need.
The failure mode
When the model can't categorize a link confidently (paywalled pages, ambiguous content, broken URLs), the link lands in an Uncategorized state with a manual override option. The link is always there, always accessible. The AI is an additive layer on top of the data, so when it fails, the link stays available.
The AI provider layer has a fallback: if OpenRouter returns a 402 (credits exhausted), the system automatically routes to OpenAI with a 1-hour retry window. Service status is tracked in KV: provider, last error, failed attempts, last success time. Users never see a provider switch.
Content extraction uses silent failure: if it times out at 10 seconds, the link still works with its metadata. I prioritized response speed here, because a link in the grid already serves the user, even without the full content extracted.
Row-level security is enforced at the database level and in the application layer. Every user data table has RLS policies bound to auth.uid(). No application bug can leak one user's data to another.
What's next
I want to add deep reading via Firecrawl, extracting actual content from saved links beyond the metadata. And Gmail OAuth to pull newsletters and threads directly into the library, turning the inbox into a learning feed. What I'm most excited about is the reading layer, because that's what will make the AI responses genuinely useful, grounded in what you actually read.
