User research is expensive and slow. Recruiting participants, scheduling sessions, running moderated tests, synthesising qualitative data — a single usability study can take three weeks and cost thousands. For early-stage features and rapid product iteration, that cadence is disqualifying.
PersonaForge is an AI synthetic user research platform that compresses that cycle to minutes. You describe a product experience, specify the user personas you'd test it with, and the platform simulates realistic user interactions and generates structured research insights.
Here's how I built it during my internship at Sopra Steria.
What "Synthetic User Research" Actually Means
Before this becomes science fiction, let me be precise about what the system does and doesn't do.
What it does: Simulate how a described user archetype — with specified goals, behaviours, technical literacy, and motivations — would respond to a product flow or interface description. Identify friction points, confusion moments, and emotional reactions based on the persona's characteristics.
What it doesn't do: Replace real user research for final decisions. Synthetic research is best for early-stage screening, hypothesis generation, and identifying obvious friction before investing in real sessions.
The platform is explicitly positioned as a pre-research tool, not a substitute.
The Multi-Agent Architecture
PersonaForge uses three specialised agents for each simulation run:
Persona Definition Agent
↓
Simulation Agent (one instance per persona)
↓
Synthesis Agent
Each agent has a single job and specific constraints. This is the same principle as CI Healer's LangGraph architecture — agents do better work when their scope is bounded.
Agent 1: Persona Definition
Takes a high-level description and generates a fully specified persona:
// lib/agents/personaAgent.ts
import Groq from "groq-sdk";
const groq = new Groq({ apiKey: process.env.GROQ_API_KEY });
const PersonaSchema = z.object({
name: z.string(),
age: z.number().int().min(18).max(75),
occupation: z.string(),
technicalLiteracy: z.enum(["low", "medium", "high"]),
primaryGoals: z.array(z.string()).min(2).max(4),
frustrations: z.array(z.string()).min(2).max(4),
behaviouralPatterns: z.array(z.string()),
devicePreferences: z.array(z.string()),
attentionSpan: z.enum(["short", "medium", "high"]),
riskTolerance: z.enum(["low", "medium", "high"]),
motivations: z.object({
intrinsic: z.array(z.string()),
extrinsic: z.array(z.string()),
}),
});
export async function generatePersona(
description: string,
context: ProductContext
): Promise<z.infer<typeof PersonaSchema>> {
const response = await groq.chat.completions.create({
model: "llama-3.3-70b-versatile",
messages: [
{
role: "system",
content: `You are a UX researcher specialising in persona development.
Create realistic, internally consistent user personas based on descriptions.
Personas should reflect real human complexity — they have contradictory motivations,
inconsistent behaviour, and context-dependent responses. Avoid archetypes that are
too clean or too predictable.`,
},
{
role: "user",
content: `Create a detailed persona for: ${description}
Product context: ${context.productDescription}
Industry: ${context.industry}
Return valid JSON matching the schema.`,
},
],
response_format: { type: "json_object" },
temperature: 0.8, // Higher temperature for persona variety
});
const raw = JSON.parse(response.choices[0].message.content!);
return PersonaSchema.parse(raw);
}
I chose Groq for the inference layer. Groq's LPU hardware delivers Llama 3 inference at 300-400 tokens/second — roughly 10x faster than typical GPU-based inference. For a research platform that might simulate 10 personas in parallel, inference speed directly impacts user-perceived latency.
Temperature at 0.8 for persona generation creates variety. Two calls with the same description produce meaningfully different personas — different ages, different goal prioritisations, different frustration patterns. That's intentional: real user populations have diversity, and the research value comes from exposing that diversity.
Agent 2: Simulation
This is the most complex agent. It role-plays as the generated persona interacting with the product:
// lib/agents/simulationAgent.ts
export async function simulatePersonaInteraction(
persona: Persona,
productFlow: ProductFlow,
taskDescription: string
): Promise<SimulationResult> {
// Multi-turn conversation that simulates the user session
const messages: Groq.ChatCompletionMessageParam[] = [
{
role: "system",
content: buildPersonaSystemPrompt(persona),
},
{
role: "user",
content: buildTaskPrompt(taskDescription, productFlow),
},
];
const sessionEvents: SessionEvent[] = [];
let continueSimulation = true;
let turnCount = 0;
const maxTurns = 15;
while (continueSimulation && turnCount < maxTurns) {
const response = await groq.chat.completions.create({
model: "llama-3.3-70b-versatile",
messages,
response_format: { type: "json_object" },
temperature: 0.7,
});
const turn = SessionTurnSchema.parse(
JSON.parse(response.choices[0].message.content!)
);
sessionEvents.push(...turn.events);
// Agent signals when the task is complete or abandoned
if (turn.sessionStatus === "completed" || turn.sessionStatus === "abandoned") {
continueSimulation = false;
}
// Add the agent's response to the conversation history for next turn
messages.push({
role: "assistant",
content: response.choices[0].message.content!,
});
// Add a "system narrator" message describing what happens next in the flow
if (continueSimulation) {
messages.push({
role: "user",
content: turn.nextScreenDescription ?? "Continue with the next step.",
});
}
turnCount++;
}
return {
persona,
taskDescription,
sessionEvents,
completionStatus: sessionEvents[sessionEvents.length - 1]?.sessionStatus ?? "incomplete",
totalTurns: turnCount,
};
}
function buildPersonaSystemPrompt(persona: Persona): string {
return `You are simulating a user research session as ${persona.name}.
YOUR CHARACTERISTICS:
- Age: ${persona.age}, Occupation: ${persona.occupation}
- Technical literacy: ${persona.technicalLiteracy}
- Primary goals: ${persona.primaryGoals.join("; ")}
- Known frustrations: ${persona.frustrations.join("; ")}
- Attention span: ${persona.attentionSpan}
- Risk tolerance: ${persona.riskTolerance}
SIMULATION RULES:
- Stay in character — your responses should be consistent with these characteristics
- Express genuine confusion when something doesn't match your mental model
- Express frustration when interactions require more effort than expected
- Show delight when something works better than expected
- Don't be uniformly positive — real users have friction points
- Your technical literacy level affects what you notice and what confuses you
For each interaction turn, output a JSON object with:
- actions: what you do in the interface
- thoughts: internal monologue as the user
- emotions: emotional reactions (0-10 scale for confusion, frustration, delight, confidence)
- events: structured events (friction_point, success_moment, confusion, task_abandoned, task_completed)
- sessionStatus: in_progress | completed | abandoned
- nextScreenDescription: description of what you expect to see next (if continuing)`;
}
The multi-turn conversation structure is what makes the simulation feel realistic. Each turn adds to the conversation history, so the agent's responses are contextually consistent. A confused user from turn 3 carries that confusion into turn 4 — the way a real user would.
The maxTurns limit prevents runaway sessions but is high enough (15 turns) for complex flows.
Agent 3: Synthesis
After all persona simulations complete, the synthesis agent aggregates the findings:
// lib/agents/synthesisAgent.ts
export async function synthesiseResearch(
sessions: SimulationResult[],
productContext: ProductContext
): Promise<ResearchReport> {
// Aggregate raw events from all sessions
const allEvents = sessions.flatMap((s) =>
s.sessionEvents.map((e) => ({ ...e, persona: s.persona.name }))
);
const frictionPoints = allEvents.filter((e) => e.type === "friction_point");
const confusionMoments = allEvents.filter((e) => e.type === "confusion");
const successMoments = allEvents.filter((e) => e.type === "success_moment");
const abandonments = allEvents.filter((e) => e.type === "task_abandoned");
const response = await groq.chat.completions.create({
model: "llama-3.3-70b-versatile",
messages: [
{
role: "system",
content: `You are a UX research analyst synthesising findings from multiple
user research simulations. Generate structured insights in the format of a
real research report — specific, actionable, and prioritised by impact.`,
},
{
role: "user",
content: `Synthesise these research findings into a report:
Product: ${productContext.productDescription}
Personas tested: ${sessions.map((s) => s.persona.name).join(", ")}
Friction points (${frictionPoints.length}):
${JSON.stringify(frictionPoints.slice(0, 20), null, 2)}
Confusion moments (${confusionMoments.length}):
${JSON.stringify(confusionMoments.slice(0, 20), null, 2)}
Success moments (${successMoments.length}):
${JSON.stringify(successMoments.slice(0, 20), null, 2)}
Task abandonments (${abandonments.length}):
${JSON.stringify(abandonments, null, 2)}
Completion rates by persona:
${sessions.map((s) => `${s.persona.name}: ${s.completionStatus}`).join("\n")}`,
},
],
response_format: { type: "json_object" },
temperature: 0.3, // Lower temperature for synthesis — want consistency
});
const raw = JSON.parse(response.choices[0].message.content!);
return ResearchReportSchema.parse(raw);
}
Temperature at 0.3 for synthesis (vs 0.7-0.8 for generation) is intentional. Research synthesis should be consistent and deterministic — you want the same findings to produce the same report. Persona generation benefits from variety; insight extraction doesn't.
Running Simulations in Parallel
Ten personas simulated sequentially would take 10x as long. I run them concurrently using Promise.allSettled:
// lib/orchestrator.ts
export async function runResearchRun(
run: ResearchRun,
productFlow: ProductFlow
): Promise<void> {
await db.researchRun.update({
where: { id: run.id },
data: { status: "running", startedAt: new Date() },
});
try {
// Generate all personas first (fast — typically < 5s for all)
const personas = await Promise.all(
run.personaDescriptions.map((desc) =>
generatePersona(desc, run.productContext)
)
);
// Run all simulations in parallel — the slow step
const results = await Promise.allSettled(
personas.map((persona) =>
simulatePersonaInteraction(persona, productFlow, run.taskDescription)
)
);
const successfulResults = results
.filter((r): r is PromiseFulfilledResult<SimulationResult> => r.status === "fulfilled")
.map((r) => r.value);
if (successfulResults.length === 0) {
throw new Error("All simulations failed");
}
// Synthesise from whatever succeeded
const report = await synthesiseResearch(successfulResults, run.productContext);
await db.researchRun.update({
where: { id: run.id },
data: {
status: "completed",
completedAt: new Date(),
report: report as unknown as Prisma.JsonObject,
completedPersonas: successfulResults.length,
},
});
} catch (err) {
await db.researchRun.update({
where: { id: run.id },
data: { status: "failed", error: String(err) },
});
throw err;
}
}
Promise.allSettled is the right choice here: if one persona simulation fails (Groq rate limit, malformed response), the other nine continue and their results feed into the synthesis. A partial research run is much more valuable than no research run.
The Dashboard
The research dashboard streams run status using Server-Sent Events so researchers see progress in real time:
// app/api/runs/[id]/stream/route.ts
export async function GET(
req: Request,
{ params }: { params: { id: string } }
) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const sendEvent = (data: object) => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
);
};
// Poll database for status updates every 2 seconds
let completed = false;
while (!completed) {
const run = await db.researchRun.findUnique({
where: { id: params.id },
include: { sessions: { select: { personaName: true, status: true } } },
});
if (!run) break;
sendEvent({
type: "status_update",
runStatus: run.status,
sessions: run.sessions,
progress: {
completed: run.sessions.filter((s) => s.status === "completed").length,
total: run.sessions.length,
},
});
if (run.status === "completed" || run.status === "failed") {
completed = true;
} else {
await new Promise((r) => setTimeout(r, 2000));
}
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
What I'd Do Differently
Use LangGraph instead of manual orchestration. I built the agent coordination manually. LangGraph's stateful graph would give me retry logic, persistence, and observability for free — the same patterns I used in CI Healer, but applied to research orchestration.
Add confidence scoring to the synthesis. When three of ten personas experience friction at the same step, that's a high-confidence finding. When one does, it's low confidence. The current synthesis doesn't surface this distinction clearly enough.
Video replay of simulated sessions. The simulation events contain enough information to render a visual replay of the session — screen-by-screen, with the persona's emotional state overlaid. That would make the research output dramatically more interpretable than a text report.
The Lesson
Multi-agent AI systems are powerful precisely because specialised agents are better than generalist agents. A single "do user research" prompt produces shallow, hedged output. Three agents with specific, bounded jobs — define persona, simulate session, synthesise findings — produce specific, actionable research.
The architecture mirrors how real research teams work: one person defines the participants, multiple sessions run in parallel with different participants, and an analyst synthesises the findings at the end. The LLM is the researcher. The structure is the discipline.