Persona

An AI powered UX research tool that turns one product description into a full discovery package: three grounded personas, user journeys, jobs to be done, interview questions, and product insights. Generated entirely on your own machine.

AI Product Design Engineering Next.js · TypeScript Local LLM Solo Build
Persona landing page: Turn product ideas into research backed user personas

At a Glance

Role: Design Engineer, designed and built solo end to end
Platform: Local first web app, Next.js with Ollama
Outcome: Shipped: a full discovery package generated in about a minute, fully local

Research kickoff, compressed to a minute

Persona is a web tool for product teams who skip early discovery because it feels expensive. You describe what you're building, and in about a minute you have three specific personas, a five stage user journey, jobs to be done, ten interview questions, and a set of product insights. Every field is editable, exportable, and grounded in real user complaints scraped from Reddit and the App Store.

What makes it different from pasting a prompt into a chatbot is the engineering around the model. Persona runs its inference on a small local model through Ollama, which means product ideas never leave the machine. It also means the entire codebase is shaped around one constraint: a 3B parameter model cannot be trusted to follow instructions. Most of this case study is about the decisions that constraint forced.

100% Local Inference
7 API Routes
3 Export Formats

My Role

Design Engineer · Designed and built the entire product solo: UX, interface, AI pipeline, testing, and CI

Stack

Next.js 16 · React 19 · TypeScript · Tailwind CSS v4 · shadcn/ui · Zod · Ollama · Vitest · Playwright

Persona landing page showing the full feature grid and an example generated persona set

The landing page: one product description in, a full discovery package out

See it work

A full walkthrough of the tool: describing a product, generating the research package, editing fields inline, interviewing a persona in character, and exporting the results.

Persona walkthrough: generation, inline editing, persona interviews, and export

Persona work gets skipped, and AI personas are generic

Early stage teams know they should start with users, but real discovery takes weeks they don't have, so they either skip it entirely or ask a chatbot, which returns the same interchangeable "Sarah, a busy professional who values convenience" for every product in every industry.

There's a second problem underneath the first: the teams most likely to want AI help with discovery are working on unreleased product ideas. Pasting those ideas into a hosted AI service means shipping your most sensitive thinking to someone else's servers. For a tool whose entire input is "describe the thing you haven't launched yet," that's not a footnote. It's a dealbreaker.

How Might We

Give product teams a real research starting point in minutes, specific enough to act on and honest about its own assumptions, without their product ideas ever leaving their machine?

Those two requirements pull against each other. Privacy points to a small local model; quality points to a large hosted one. Persona's answer was to take the small model and build enough engineering around it that its output becomes trustworthy. That tradeoff, and everything it forced, is the heart of this project.

Step one of the creation wizard asking What are you building, with a three step progress indicator and a note that generation happens locally via Ollama

The three step wizard opens with one question and a promise: generated locally, no data leaves your machine

Five artifacts, one workspace

The output isn't a wall of text. It's a structured workspace with five research artifacts, each one editable field by field, regenerable section by section, and exportable as Markdown, JSON, or a designed PDF.

Personas

Three distinct personas, each with a profile summary, goals, pain points, motivations, and a first person quote. The system prompt bans genericism explicitly: every persona must be specific enough that two different products in the same industry would produce different people.

Three persona cards: Aisha Okafor a fintech product manager, Marcus Webb a corporate law partner, and Jordan Kim a freelance UX designer, each with goals, pain points, motivations, and a quote

Three personas for a habit tracking app: a PM, a law partner, and a freelancer, each with different reasons to care

User Journey and Jobs To Be Done

The journey maps five stages from Awareness to Long Term Usage, each with user actions, thoughts, emotions, pain points, and opportunities. Jobs to be done frames each persona's motivation as a when / I want to / so I can story.

User journey board with columns for Awareness, Discovery, Decision, and First Use, each listing user actions, thoughts, emotions, pain points, and opportunities

The journey: five stages, each broken into actions, thoughts, emotions, pain points, and opportunities

Jobs To Be Done cards for each persona structured as When, I Want To, So I Can statements

One job story per persona: when a sprint goes sideways, I want to reschedule without a guilt spiral

Interview Questions and Product Insights

Ten qualitative interview questions grouped by research category, ready for real user sessions, plus product insights that go beyond validation: feature opportunities, MVP recommendations, design considerations, potential risks, and the research assumptions the idea silently rests on.

Interview questions grouped into Background, Current Behavior, Pain Points, Motivations, and Product Validation categories

Ten questions across five categories, written to be asked out loud in a real session

Product insights panel with feature opportunities, MVP recommendations, design considerations, potential risks, and research assumptions

Insights include a section most AI tools omit: the assumptions worth testing before you build

Interview a Persona

Any persona can be interviewed in a chat interface. The model role plays the persona from their generated profile, and after the conversation it proposes updates to the persona's fields based on what was said, which the user reviews as a per field diff and applies selectively. The AI suggests; the human decides.

Interview simulation chat with persona Aisha Okafor, who introduces herself and invites questions about her work and frustrations

Interview simulation: pressure test your questions against the persona before spending real participants on them

Projects, Comparison, and Dark Mode

Every generation is saved as a project. Two runs can be compared side by side, with personas matched by similarity rather than position, to see what changed between iterations of an idea. The whole interface ships in light and dark themes.

My Projects page listing a saved research project with its three personas and Open and Compare actions
The persona workspace rendered in dark mode

Saved projects with side by side comparison, and the workspace in dark mode

A thin app around a careful pipeline

Persona is a Next.js 16 app with seven API routes, localStorage as the only database, and one heavily defended path from user input to validated output. There is no backend state, no auth, and no vector database. Every piece of infrastructure had to earn its place.

The flow end to end: a three step wizard (React Hook Form, with each step validated against a slice of a single Zod schema) collects the product description and context. An optional research phase scrapes Reddit and App Store reviews for real user language. The generate route sends everything to the local model, runs the output through a three tier recovery pipeline, and the result lands in localStorage. The results workspace then merges the generated data with any hand edits at render time.

app/ · route structure
app/create        →  3-step wizard (idea → context → generate)
app/results/[id]  →  the research workspace
app/interview/…   →  in-character persona chat
app/projects      →  saved projects
app/compare       →  two runs, diffed side by side
app/demo          →  seeded demo, no model required

api/generate      →  full package generation
api/regenerate    →  single-section regeneration
api/research      →  Reddit + App Store grounding
api/interview     →  persona role-play chat
api/interview/update-persona  →  transcript → suggested edits
api/flag-assumptions          →  grounded-vs-assumption audit
api/models        →  lists installed Ollama models

One route deserves a special mention: /demo seeds a complete pre generated project into localStorage and opens the workspace. Anyone reviewing the tool, including you watching the video above, can explore every feature without installing a model. The demo isn't a mock; it's the real workspace running on canned data.

Design Constraint

Because persistence is localStorage, nearly every stateful component must be a client component. That's not an accident of laziness. It's the architectural price of the privacy promise. No server database means no server rendering of user data, and the code is organized to make that boundary explicit.

Designing for a model that will let you down

Persona began on a hosted frontier model and was deliberately moved to a small local one. Every decision below is a consequence of that move, and together they're the real product: the difference between "a prompt wrapper" and a tool that produces trustworthy output from an untrustworthy narrator.

1 · Run local, trust nothing

The entire provider layer is eight lines. Ollama exposes an OpenAI compatible endpoint, so the official SDK points at localhost and the default model is a 3B parameter Llama: small enough to run on a laptop, private by construction, free per token.

lib/ai/client.ts · the whole provider layer
import OpenAI from "openai";

export const client = new OpenAI({
  baseURL: "http://localhost:11434/v1",
  apiKey: "ollama", // required by SDK but ignored by Ollama
});

export const MODEL = process.env.OLLAMA_MODEL || "llama3.2:latest";

The cost of that choice is capability. A frontier model follows a JSON schema on the first try; a 3B model returns markdown fences, invents Unicode decorations, forgets fields, and under fills lists. The original build constrained output with forced tool use and strict schemas. The small model simply couldn't comply, so that entire approach was abandoned for a prose "output contract" in the prompt plus defensive validation on the way out. The old tool schema file still sits in the repo as dead code: a fossil of the architecture the constraint killed.

2 · Two schemas: lenient in, strict out

The core validation idea is an asymmetry. There are two parallel Zod schemas: a lenient one for whatever the model produces (every field defaulted, enums with fallbacks, array minimums of one) and a strict one for what the app is allowed to render. A mapper is the only bridge between them.

lib/ai/raw-schema.ts · accepting reality
// Relaxed schema for local model output — strict counts are enforced by the
// prompt, not the validator. We accept whatever the model produces and map it.
gender: z.enum(["male", "female", "neutral"]).catch("neutral"),

If the strict schema had been applied to the model directly, most generations would fail outright. If the lenient schema had been used everywhere, garbage would reach the UI. Splitting them means the model gets grace and the interface gets guarantees, and every quirk fix lives in exactly one place, the mapper, where it can be unit tested.

Some of those quirk fixes are wonderfully specific. The model uses stray Unicode modifier letters as decoration; it wraps persona names in angle brackets, which would corrupt the avatar initials; it sometimes returns a comma separated string where a list belongs. Each observed failure got a named, tested function:

lib/ai/generate.ts · cleaning up after llama3.2
// Strip stray Unicode characters llama3.2 uses as fake formatting markers.
export function sanitizeStr(s: string): string {
  return s.replace(/[Íí]/g, "").replace(/[ʰ-˿]/g, "")
          .replace(/\s{2,}/g, " ").trim();
}

// Strip wrapper punctuation the model leaves around a persona name,
// e.g. "<Alex, Clinic Manager>". Only the two ends are touched, so
// punctuation inside a name survives (O'Brien, Mary-Jane).

3 · Three tiers of recovery, not one retry

When output fails validation, the naive move is to regenerate and hope. Persona instead escalates through three tiers, each matched to a different failure mode.

Tier one handles formatting: strip markdown fences, parse. Tier two handles schema failures with a repair turn: the model is shown its own output and the exact dotted field paths that failed, so the second attempt is a correction rather than a fresh roll of the dice:

lib/ai/generate.ts · the repair turn
// Show the model its own output and the exact fields that failed, so the
// second attempt is a correction rather than a fresh roll of the dice.
// Most failures are one or two wrong types, not a wholly wrong shape.
const repairMessages: Message[] = [
  ...messages,
  { role: "assistant", content: JSON.stringify(candidate) },
  { role: "user", content:
      "That JSON did not validate. These fields are wrong:\n" +
      describeIssues(parsed.error.issues) + … },
];

Tier three handles the failure nobody plans for: output that is valid but under filled. Small models routinely return two personas instead of three, or one interview question per category instead of two. That's not a schema error, so it gets its own round: a top up request whose result is merged gap by gap, never wholesale, guarded by a rule that the retry is only accepted if it strictly improved things:

lib/ai/generate.ts · the shortfall top up
const remaining = [...personaShortfall(candidate.personas),
                   ...interviewShortfall(candidate.interviewQuestions)];
if (remaining.length < shortfall.length) {
  data = candidate;  // only accept the retry if it strictly improved things
}

The merge functions carry the same care: new personas fill gaps in position so the jobs to be done indices still line up, and a retry that fixes one category can't silently drop another. And if the repair call itself throws, the original validation error is preserved for the log rather than being masked by the newer, less informative one.

4 · The decision I reversed: no fake data

The most instructive decision in the codebase is one I got wrong first. Early versions padded output to exact counts, always three personas and always ten questions, to keep the schema simple. The result was a card literally named "Unknown" with a dash in every field. Structurally valid, honestly a lie.

The fix ran through three files, and each one documents its reasoning at the site of the change. The schema's exact count became a ceiling, and the section header now reports the real count:

types/schema.ts · a target, not a guarantee
// Three personas is the target the prompt asks for, not a guarantee the model
// always meets. An exact length forced the mapper to pad with a blank persona
// that rendered as a card named "Unknown", so the bound is a ceiling now and
// the section header reports the real count.
export const personasArraySchema = z.array(personaSchema).min(1).max(3);

The same doctrine shows up where jobs to be done reference personas by index: the index is clamped to the personas that actually exist, because as the comment puts it, padding would leave "a card pointing at no persona at all, which renders as 'Unknown persona': the same defect wearing a different hat." And when regenerating a single persona, the pipeline now fails loudly rather than degrading silently:

lib/ai/regenerate.ts · failing is the honest outcome
// Falling back to a name of "Unknown" here would put the very card the
// mapper no longer invents back on screen, and it would do it by
// replacing a persona that was fine. Failing is the honest outcome: the
// UI reports it and the existing persona stays put.
if (!isRealPersona({ ...parsed, name })) {
  throw new RegenerationError("Model returned an incomplete persona");
}
Principle

In an AI product, a confident blank is worse than an honest gap. Show two real personas and say "two," rather than three cards where one is furniture. The reversal is locked in by regression tests named after the original bug.

5 · Grounding without a vector database

The anti genericism promise needs real user language, but a vector database would be infrastructure overkill for a local first tool. The research route is a deliberately simple alternative: scrape Reddit search and App Store reviews in parallel, filter for quality, and inject the survivors directly into the prompt as quoted evidence. Each source fails silently; research is an enhancement, never a dependency:

app/api/research/route.ts · degrade, don't block
const [redditSnippets, appStoreSnippets] = await Promise.all([
  fetchRedditSnippets(wizardInput).catch(() => [] as string[]),
  competitorApp?.trim()
    ? fetchAppStoreSnippets(competitorApp.trim()).catch(() => [])
    : Promise.resolve([]),
]);

Queries are built by a hand rolled keyword extractor (stop word list, length filter, top five terms) plus a map from the selected audience to search vocabulary. Reddit results are filtered by score, truncated to 260 characters, and annotated with upvotes only when they're high enough to mean something. The App Store fetch is a two hop dance through the iTunes Search API to the review RSS feed, skipping the first entry because it's app metadata, not a review. The assembled block then instructs the model to anchor its personas in that language, using the users' specific words, frustrations, and emotional tone.

6 · State without a store: edits that survive regeneration

Every project stores the generated result and the user's hand edits separately, merged only at render time. This looks like extra bookkeeping until you hit the interaction that motivates it: a user edits a field, then regenerates that section. If edits were written into the data, the stale hand edit would either overwrite the fresh AI answer or resurrect after it.

lib/storage.ts · generation and edits, kept apart
// Inline-edit overrides — kept separate from generatedResult so a later
// "regenerate this section" call can cleanly discard stale hand-edits
// without accidentally reviving them.

Reads are schema validated per project, so one corrupt entry can't nuke the rest of the workspace. That policy has consequences that are documented where they bite: a later added field must stay optional in the schema, because requiring it would silently discard every project saved before the field existed. Backward compatibility as a comment you can't miss, exactly where a future change would break it.

7 · One drawing, three renderers

Personas need faces. Persona ships sixteen hand drawn SVG avatars, and the engineering problem is that three different renderers consume them: the DOM, the Markdown export (which must inline the SVG so the file stands alone), and the PDF, whose renderer doesn't accept SVG markup at all, only element props. A build script parses each SVG once and code generates a catalog carrying both the raw markup and a parsed shape tree, so the three renderers can never drift apart. The build fails loudly if an artist uses an SVG feature the PDF renderer can't draw.

Assignment is deterministic: a persona's face is chosen by hashing its id, never its content, so renaming a persona or rewording a goal can't shuffle the face out from under the user:

lib/avatars/index.ts · stable faces, spread suggestions
/** FNV-1a (32-bit). Small, dependency-free, and stable across reloads. */
function hash(seed: string): number {
  let h = 0x811c9dc5;
  for (let i = 0; i < seed.length; i++) {
    h ^= seed.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return h >>> 0;
}

// Stepping by a number coprime with the catalog size walks all 16 without
// repeating, so the suggestions spread across the set instead of clustering.
const PICKER_STRIDE = 5;

The smaller calls that add up

Why does generation take a minimum time?

The wizard enforces an artificial floor on the loading phase, 5.2 seconds for generation, racing the fetch against a timer. A fast model that returns in one second makes a staged progress narrative look broken and the output feel cheap. The rotating status messages exist to teach what's being built; the floor guarantees they're actually seen. A deliberate trade of raw speed for comprehension.

Why doesn't the interview auto apply its findings?

After interviewing a persona, the model proposes profile updates from the transcript, but the API whitelists which fields it may touch (never the name or identity), and the user reviews every change as a per field diff before applying it. In a research tool, the moment the AI silently rewrites your persona is the moment you stop trusting the persona. Human in the loop wasn't a feature; it was the trust model.

Why is there a "-" sentinel everywhere?

Strict schemas need array minimums, but honest output sometimes has fewer items. The compromise is a single sentinel value, "-", honored by every consumer: editors treat it as empty, exports filter it, and a card whose every field is the sentinel triggers a "generation may have failed" warning with an inline regenerate. One convention, enforced end to end, instead of scattered special cases.

Why does the app check what models you have?

A local first tool can't assume its runtime exists. An API route asks Ollama what's actually installed; if it's unreachable, the wizard shows an amber banner with install instructions instead of failing at generation time, and the model picker only renders when there's genuinely a choice to make. Detect the environment, then design for its absence.

Tests named after real bugs

The test suite isn't coverage theater. Unit tests target the AI pipeline's pure functions, several named after the exact production bug they lock down; end to end tests read state off the rendered DOM; and CI runs unit, build, and e2e jobs in parallel with no model required.

The pipeline's cleanup functions are exported with an @internal marker specifically for testability, and the regression suite asserts the absence of old behavior: the "Unknown" padding bug can't return without a test failing.

lib/ai/__tests__/generate.test.ts · locking the reversal in
it("does not pad fewer than 3 personas with blanks", () => {
  …
  expect(result.personas.some((p) => p.name === "Unknown")).toBe(false);
});

it("throws rather than returning a workspace with no personas", () => {
  expect(() => mapRawToResult(raw)).toThrow(/no usable personas/i);
});

The avatar hash gets property style tests: 400 generated ids must hit all sixteen faces, no avatar may reference a remote resource, and the PDF pipeline is proven by rendering a real PDF to a buffer in Node with all sixteen faces on one page.

The Playwright suite contains my favorite fix in the project. Avatar picker tests were flaky because Radix popovers are "visible" a frame before their outside click listener attaches, so a click fired immediately after opening could be missed. The fix waits for the popover's animations to actually finish:

e2e/avatars.spec.ts · an animation aware wait
/**
 * Waits for the popover's open animation to finish. It is in the DOM and
 * "visible" a frame before Radix attaches its outside-pointerdown listener,
 * so a click fired immediately after opening can be missed.
 */
await page.waitForFunction(() => {
  const el = document.querySelector('[data-slot="popover-content"]');
  return !!el && el.getAnimations().every((a) => a.playState === "finished");
});

The same suite verifies persistence through reload, keyboard grid navigation with focus returning to the trigger, and a mobile viewport test with a real horizontal overflow assertion and a 44px touch target check. In CI, the generate endpoint is intercepted with a fixture so the full wizard flow runs without any model installed.

Regression first

Tests are named after shipped bugs, like the ">Alex, Clinic Manager" name that reached the UI and the blank "Unknown" card, so the suite documents the project's history of failure and guards against its return.

DOM as truth

E2e helpers read the currently rendered SVG off the page rather than trusting internal state, so tests verify what the user actually sees.

Model free CI

Every AI call is mocked or intercepted in tests. The suite proves the engineering around the model without needing the model, which is the point.

Accessibility asserted

Keyboard navigation through the wizard is a dedicated test asserting aria-current moves correctly. Accessibility as a tested behavior, not an aspiration.

A working tool, and a repeatable playbook

Persona shipped as a complete, tested product: five research artifacts, inline editing everywhere, persona interviews with human reviewed updates, project comparison, three export formats, a no install demo mode, and a CI pipeline that proves all of it without a model in the loop. But the more durable outcome is the playbook: a set of patterns for building trustworthy products on top of unreliable models.

Key Finding

The quality of an AI product is decided less by the model than by what the product does when the model fails. Repair before retry, top up before discard, honest gaps before fake completeness. Those policies, not the weights, are what users experience as reliability.

Known Tradeoffs

Some gaps are deliberate and worth naming. There's no response streaming; the staged loader is the mitigation, and for a package that must be validated as a whole before display, partial output has limited value. There's no rate limiting, which is defensible for a localhost tool with no per token cost but would be the first addition if it ever fronted a hosted model. And the repo still carries fossils of the hosted model era (a dead tool schema file, unreachable error branches), honest evidence of an architecture that was tried, measured, and replaced.

What I Learned

Building Persona solo meant owning every layer, and the layers taught different lessons. The design lesson: an AI tool earns trust through its edges: diffs before applying changes, real counts instead of padded cards, warnings when generation degrades. The engineering lesson: constraints are generative. Choosing a weak model forced better validation, better recovery, better tests, and better honesty than a frontier model ever would have.

Constraint as Design Tool

The 3B local model was the project's best design decision. It made every weakness visible early and forced the systems, two schema validation and tiered recovery, that would make any model more reliable.

Write the Why Down

The codebase documents its reasoning at the site of each decision, including the reversed ones. Comments explaining why padding was removed are worth more than the code that removed it.

Honesty as UX

Showing two personas and saying "two" beats showing three where one is blank. Users forgive a model's limits; they don't forgive being lied to about them.

Test the Failure Story

The most valuable tests recreate the ways the model actually failed. A test suite that mocks perfect AI output tests a product that doesn't exist.

OutcomeShipped: a full discovery package generated in about a minute, fully local.

Next project Deposit Proof

Try the live demo, then see how it was built in the sections above.

Open to full time roles and select freelance work. Say hello, find me on LinkedIn, or use the contact page.