Episode 4 — Generative AI Engineering / 4.18 — Building a Simple Multi Agent Workflow
4.18.b — Hinge Direction: Profile Pipeline
In one sentence: This section builds a complete dating app multi-agent workflow where three specialized agents — Profile Analyzer, Bio Improver, and Conversation Starter Generator — work in sequence, each with its own system prompt and Zod-validated output schema, transforming a raw user profile into an improved bio and personalized conversation openers.
Navigation: ← 4.18.a — Multi-Agent Pipeline Design · 4.18.c — ImageKit Direction: SEO Pipeline →
1. The Hinge Profile Pipeline — Overview
Imagine you are building a feature for a dating app (like Hinge) that helps users improve their profiles and generate conversation starters. A single agent trying to do all of this at once would produce inconsistent, unfocused results. Instead, we decompose the task into three specialized agents:
┌─────────────────────────────────────────────────────────────────────────────┐
│ HINGE PROFILE PIPELINE │
│ │
│ ┌──────────┐ ┌───────────────┐ ┌───────────────┐ ┌──────────────┐ │
│ │ User │───►│ AGENT 1 │───►│ AGENT 2 │───►│ AGENT 3 │ │
│ │ Profile │ │ Profile │ │ Bio │ │ Conversation │ │
│ │ (raw) │ │ Analyzer │ │ Improver │ │ Starter │ │
│ │ │ │ │ │ │ │ Generator │ │
│ └──────────┘ └───────┬───────┘ └───────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ Zod validates Zod validates Zod validates │
│ analysis improved bio openers │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ Strengths, Rewritten bio, 5 conversation │
│ weaknesses, changes applied, openers + │
│ scores, tips improvement score context tags │
│ │
│ FINAL OUTPUT: Structured JSON with analysis + improved bio + openers │
└─────────────────────────────────────────────────────────────────────────────┘
Agent Responsibilities
| Agent | Responsibility | Input | Output |
|---|---|---|---|
| Agent 1: Profile Analyzer | Analyze the raw profile for strengths, weaknesses, and improvement opportunities | Raw user profile (name, age, bio, interests, photos description) | Analysis object: strengths[], weaknesses[], overallScore, improvementTips[] |
| Agent 2: Bio Improver | Rewrite the bio based on the analysis, keeping the user's voice | Analysis from Agent 1 + original bio | Improved bio, list of changes, improvement score |
| Agent 3: Conversation Starter Generator | Create personalized conversation openers based on the improved profile | Improved bio + original interests | Array of conversation starters with context tags |
2. Step 1 — Define the Input Schema
First, define what a raw user profile looks like:
import { z } from 'zod';
// Input: Raw user profile
const UserProfileSchema = z.object({
name: z.string().min(1),
age: z.number().min(18).max(99),
gender: z.string(),
lookingFor: z.string(),
bio: z.string().min(10),
interests: z.array(z.string()).min(1),
photoDescriptions: z.array(z.string()).optional(),
location: z.string().optional(),
promptAnswers: z.array(z.object({
prompt: z.string(),
answer: z.string(),
})).optional(),
});
Example Input
const sampleProfile = {
name: "Alex",
age: 28,
gender: "Male",
lookingFor: "Serious relationship",
bio: "Hey I'm Alex. I like hiking and cooking. I work in tech. Looking for someone chill to hang out with. I also like watching movies and traveling. Hit me up if you're interested.",
interests: ["Hiking", "Cooking", "Technology", "Movies", "Travel", "Photography"],
photoDescriptions: [
"Selfie at a mountain summit",
"Cooking pasta in a kitchen",
"Group photo at a concert",
"Photo with a dog at a park",
],
location: "San Francisco, CA",
promptAnswers: [
{ prompt: "A life goal of mine", answer: "Visit every continent" },
{ prompt: "My simple pleasures", answer: "Morning coffee and a good book" },
],
};
3. Step 2 — Agent 1: Profile Analyzer
Output Schema
const ProfileAnalysisSchema = z.object({
strengths: z.array(z.object({
category: z.enum(["bio", "interests", "photos", "prompts", "overall"]),
description: z.string().min(10),
impactScore: z.number().min(1).max(10),
})).min(1),
weaknesses: z.array(z.object({
category: z.enum(["bio", "interests", "photos", "prompts", "overall"]),
description: z.string().min(10),
severity: z.enum(["low", "medium", "high"]),
suggestion: z.string().min(10),
})).min(1),
overallScore: z.number().min(1).max(10),
profilePersonality: z.string().min(20),
improvementTips: z.array(z.string()).min(1).max(5),
toneAnalysis: z.object({
currentTone: z.string(),
suggestedTone: z.string(),
reasoning: z.string(),
}),
});
System Prompt
const profileAnalyzerPrompt = `You are an expert dating profile analyst. Your job is to analyze a dating profile and identify its strengths, weaknesses, and areas for improvement.
RULES:
1. Be constructive and specific — never vague or mean.
2. Consider what makes profiles attractive: authenticity, specificity, humor, conversation hooks.
3. Analyze the bio, interests, photo descriptions, and prompt answers.
4. Score the profile from 1-10 (10 = exceptional).
5. Identify the personality that comes through (or doesn't).
6. Suggest a tone that would work better if the current tone is weak.
OUTPUT FORMAT: Respond with ONLY valid JSON matching this exact structure:
{
"strengths": [{ "category": "bio|interests|photos|prompts|overall", "description": "...", "impactScore": 1-10 }],
"weaknesses": [{ "category": "bio|interests|photos|prompts|overall", "description": "...", "severity": "low|medium|high", "suggestion": "..." }],
"overallScore": 1-10,
"profilePersonality": "Description of personality that comes through",
"improvementTips": ["tip1", "tip2", ...],
"toneAnalysis": { "currentTone": "...", "suggestedTone": "...", "reasoning": "..." }
}
Respond with ONLY the JSON object. No markdown, no explanation, no code fences.`;
Agent 1 Implementation
import OpenAI from 'openai';
const client = new OpenAI();
async function runProfileAnalyzer(profile) {
console.log("\n--- Agent 1: Profile Analyzer ---");
const response = await client.chat.completions.create({
model: "gpt-4o",
temperature: 0.7,
messages: [
{ role: "system", content: profileAnalyzerPrompt },
{ role: "user", content: JSON.stringify(profile) },
],
});
const rawOutput = response.choices[0].message.content;
if (!rawOutput) {
throw new Error("Profile Analyzer returned empty response");
}
// Parse JSON
let parsed;
try {
parsed = JSON.parse(rawOutput);
} catch {
const jsonMatch = rawOutput.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonMatch) {
parsed = JSON.parse(jsonMatch[1].trim());
} else {
throw new Error(`Profile Analyzer returned invalid JSON: ${rawOutput.substring(0, 300)}`);
}
}
// Validate with Zod
const validated = ProfileAnalysisSchema.parse(parsed);
console.log(`Profile Analyzer: Score ${validated.overallScore}/10, ${validated.strengths.length} strengths, ${validated.weaknesses.length} weaknesses`);
return validated;
}
4. Step 3 — Agent 2: Bio Improver
Output Schema
const ImprovedBioSchema = z.object({
improvedBio: z.string().min(20).max(500),
changesApplied: z.array(z.object({
changeType: z.enum(["rewrite", "addition", "removal", "restructure", "tone-shift"]),
description: z.string().min(10),
before: z.string(),
after: z.string(),
})).min(1),
improvementScore: z.number().min(1).max(10),
hookCount: z.number().min(0),
conversationStarters: z.array(z.string()).min(1),
preservedElements: z.array(z.string()),
wordCount: z.number(),
});
System Prompt
const bioImproverPrompt = `You are an expert dating profile bio writer. You take a profile analysis (strengths, weaknesses, tips) and rewrite the user's bio to be more attractive, authentic, and engaging.
RULES:
1. PRESERVE the user's personality and voice — don't make them sound like someone else.
2. Fix weaknesses identified in the analysis.
3. Add conversation hooks — specific details people can ask about.
4. Keep it concise — dating app bios should be punchy, not essays.
5. Include at least 2 "conversation hooks" (specific, interesting details).
6. Use the suggested tone from the analysis.
7. Track every change you make.
INPUT: You'll receive the original bio, the analysis from the Profile Analyzer, and the user's interests.
OUTPUT FORMAT: Respond with ONLY valid JSON matching this exact structure:
{
"improvedBio": "The rewritten bio text",
"changesApplied": [{ "changeType": "rewrite|addition|removal|restructure|tone-shift", "description": "...", "before": "original text", "after": "new text" }],
"improvementScore": 1-10,
"hookCount": number,
"conversationStarters": ["question someone might ask based on the bio"],
"preservedElements": ["elements kept from original"],
"wordCount": number
}
Respond with ONLY the JSON object. No markdown, no explanation, no code fences.`;
Agent 2 Implementation
async function runBioImprover(analysis, originalProfile) {
console.log("\n--- Agent 2: Bio Improver ---");
// Selective context: Agent 2 gets the analysis + original bio + interests
const input = {
originalBio: originalProfile.bio,
interests: originalProfile.interests,
name: originalProfile.name,
analysis: {
weaknesses: analysis.weaknesses,
improvementTips: analysis.improvementTips,
toneAnalysis: analysis.toneAnalysis,
profilePersonality: analysis.profilePersonality,
},
};
const response = await client.chat.completions.create({
model: "gpt-4o",
temperature: 0.8,
messages: [
{ role: "system", content: bioImproverPrompt },
{ role: "user", content: JSON.stringify(input) },
],
});
const rawOutput = response.choices[0].message.content;
if (!rawOutput) {
throw new Error("Bio Improver returned empty response");
}
let parsed;
try {
parsed = JSON.parse(rawOutput);
} catch {
const jsonMatch = rawOutput.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonMatch) {
parsed = JSON.parse(jsonMatch[1].trim());
} else {
throw new Error(`Bio Improver returned invalid JSON: ${rawOutput.substring(0, 300)}`);
}
}
const validated = ImprovedBioSchema.parse(parsed);
console.log(`Bio Improver: Score ${validated.improvementScore}/10, ${validated.changesApplied.length} changes, ${validated.hookCount} hooks`);
return validated;
}
5. Step 4 — Agent 3: Conversation Starter Generator
Output Schema
const ConversationStartersSchema = z.object({
openers: z.array(z.object({
text: z.string().min(10).max(300),
style: z.enum(["playful", "curious", "witty", "sincere", "bold", "thoughtful"]),
targetInterest: z.string(),
conversationDepth: z.enum(["light", "medium", "deep"]),
reasoning: z.string().min(10),
})).min(3).max(7),
iceBreakers: z.array(z.object({
text: z.string().min(10).max(200),
context: z.string(),
})).min(2).max(5),
bestOpeningLine: z.object({
text: z.string(),
whyItWorks: z.string(),
}),
profileStrengthUsed: z.array(z.string()),
});
System Prompt
const conversationStarterPrompt = `You are an expert at crafting dating app conversation starters. Given an improved profile bio and the user's interests, generate creative, personalized conversation openers.
RULES:
1. Each opener MUST reference something specific from the bio or interests — no generic "Hey, how are you?"
2. Vary the styles: some playful, some curious, some witty, some sincere.
3. Include openers for different conversation depths (light, medium, deep).
4. Consider what the OTHER person would find engaging to respond to.
5. Each opener should naturally lead to a back-and-forth conversation.
6. Never be creepy, overly forward, or use pickup lines.
INPUT: You'll receive the improved bio and the user's interests.
OUTPUT FORMAT: Respond with ONLY valid JSON matching this exact structure:
{
"openers": [{ "text": "...", "style": "playful|curious|witty|sincere|bold|thoughtful", "targetInterest": "which interest this targets", "conversationDepth": "light|medium|deep", "reasoning": "why this works" }],
"iceBreakers": [{ "text": "...", "context": "when to use this" }],
"bestOpeningLine": { "text": "...", "whyItWorks": "..." },
"profileStrengthUsed": ["which profile elements these openers leverage"]
}
Respond with ONLY the JSON object. No markdown, no explanation, no code fences.`;
Agent 3 Implementation
async function runConversationStarterGenerator(improvedBio, originalProfile) {
console.log("\n--- Agent 3: Conversation Starter Generator ---");
const input = {
improvedBio: improvedBio.improvedBio,
conversationHooks: improvedBio.conversationStarters,
interests: originalProfile.interests,
name: originalProfile.name,
lookingFor: originalProfile.lookingFor,
promptAnswers: originalProfile.promptAnswers,
};
const response = await client.chat.completions.create({
model: "gpt-4o",
temperature: 0.9, // Higher temperature for creative outputs
messages: [
{ role: "system", content: conversationStarterPrompt },
{ role: "user", content: JSON.stringify(input) },
],
});
const rawOutput = response.choices[0].message.content;
if (!rawOutput) {
throw new Error("Conversation Starter Generator returned empty response");
}
let parsed;
try {
parsed = JSON.parse(rawOutput);
} catch {
const jsonMatch = rawOutput.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonMatch) {
parsed = JSON.parse(jsonMatch[1].trim());
} else {
throw new Error(`Conversation Starter Generator returned invalid JSON: ${rawOutput.substring(0, 300)}`);
}
}
const validated = ConversationStartersSchema.parse(parsed);
console.log(`Conversation Starter Generator: ${validated.openers.length} openers, ${validated.iceBreakers.length} ice breakers`);
return validated;
}
6. Step 5 — The Complete Pipeline
Final Output Schema
const HingePipelineOutputSchema = z.object({
originalProfile: UserProfileSchema,
analysis: ProfileAnalysisSchema,
improvedBio: ImprovedBioSchema,
conversationStarters: ConversationStartersSchema,
pipelineMetadata: z.object({
totalDuration: z.number(),
agentCount: z.number(),
timestamp: z.string(),
}),
});
End-to-End Pipeline
async function runHingeProfilePipeline(rawProfile) {
console.log("\n╔══════════════════════════════════════════════════╗");
console.log("║ HINGE PROFILE PIPELINE — START ║");
console.log("╚══════════════════════════════════════════════════╝");
const pipelineStart = Date.now();
// Validate input
const profile = UserProfileSchema.parse(rawProfile);
console.log(`\nInput validated: ${profile.name}, age ${profile.age}`);
// Step 1: Analyze the profile
const analysis = await runProfileAnalyzer(profile);
// Step 2: Improve the bio (needs analysis + original profile)
const improvedBio = await runBioImprover(analysis, profile);
// Step 3: Generate conversation starters (needs improved bio + original profile)
const conversationStarters = await runConversationStarterGenerator(improvedBio, profile);
const totalDuration = Date.now() - pipelineStart;
// Assemble final output
const finalOutput = {
originalProfile: profile,
analysis,
improvedBio,
conversationStarters,
pipelineMetadata: {
totalDuration,
agentCount: 3,
timestamp: new Date().toISOString(),
},
};
// Validate the entire output
const validated = HingePipelineOutputSchema.parse(finalOutput);
console.log("\n╔══════════════════════════════════════════════════╗");
console.log("║ HINGE PROFILE PIPELINE — COMPLETE ║");
console.log(`║ Total time: ${totalDuration}ms ║`);
console.log("╚══════════════════════════════════════════════════╝");
return validated;
}
Running the Pipeline
// Execute the pipeline
async function main() {
try {
const result = await runHingeProfilePipeline(sampleProfile);
console.log("\n=== RESULTS ===\n");
// Display analysis summary
console.log(`Profile Score: ${result.analysis.overallScore}/10`);
console.log(`Personality: ${result.analysis.profilePersonality}`);
console.log(`\nStrengths:`);
result.analysis.strengths.forEach(s =>
console.log(` [${s.category}] ${s.description} (impact: ${s.impactScore}/10)`)
);
console.log(`\nWeaknesses:`);
result.analysis.weaknesses.forEach(w =>
console.log(` [${w.category}] ${w.description} (severity: ${w.severity})`)
);
// Display improved bio
console.log(`\n--- Original Bio ---`);
console.log(result.originalProfile.bio);
console.log(`\n--- Improved Bio ---`);
console.log(result.improvedBio.improvedBio);
console.log(`\nImprovement: ${result.improvedBio.improvementScore}/10`);
console.log(`Changes: ${result.improvedBio.changesApplied.length}`);
// Display conversation starters
console.log(`\n--- Conversation Starters ---`);
result.conversationStarters.openers.forEach((opener, i) =>
console.log(` ${i + 1}. [${opener.style}] "${opener.text}"`)
);
console.log(`\nBest opener: "${result.conversationStarters.bestOpeningLine.text}"`);
console.log(`Why: ${result.conversationStarters.bestOpeningLine.whyItWorks}`);
// Pipeline stats
console.log(`\n--- Pipeline Stats ---`);
console.log(`Total duration: ${result.pipelineMetadata.totalDuration}ms`);
console.log(`Agents used: ${result.pipelineMetadata.agentCount}`);
} catch (error) {
console.error("Pipeline failed:", error.message);
if (error.issues) {
console.error("Validation errors:", JSON.stringify(error.issues, null, 2));
}
}
}
main();
7. Understanding the Data Flow
Here is the exact data that flows between each agent in the pipeline:
INPUT (UserProfile)
│
│ name: "Alex"
│ bio: "Hey I'm Alex. I like hiking..."
│ interests: ["Hiking", "Cooking", ...]
│
▼
AGENT 1: Profile Analyzer
│
│ OUTPUT (ProfileAnalysis):
│ {
│ strengths: [{ category: "interests", description: "Diverse range...", impactScore: 7 }],
│ weaknesses: [{ category: "bio", description: "Generic opener...", severity: "high" }],
│ overallScore: 5,
│ profilePersonality: "Adventurous but hiding behind cliches",
│ improvementTips: ["Replace generic opener", "Add specific details", ...],
│ toneAnalysis: { currentTone: "casual-generic", suggestedTone: "warm-specific" }
│ }
│
▼
AGENT 2: Bio Improver
│
│ RECEIVES: { originalBio, interests, name, analysis.weaknesses,
│ analysis.improvementTips, analysis.toneAnalysis }
│
│ OUTPUT (ImprovedBio):
│ {
│ improvedBio: "Summit-chasing software engineer who thinks the best...",
│ changesApplied: [{ changeType: "rewrite", description: "Replaced generic opener..." }],
│ improvementScore: 8,
│ hookCount: 3,
│ conversationStarters: ["Ask about his summit stories", ...],
│ preservedElements: ["hiking", "cooking", "travel"],
│ wordCount: 85
│ }
│
▼
AGENT 3: Conversation Starter Generator
│
│ RECEIVES: { improvedBio, conversationHooks, interests, name, lookingFor }
│
│ OUTPUT (ConversationStarters):
│ {
│ openers: [
│ { text: "What's the most underrated trail you've hiked?",
│ style: "curious", targetInterest: "Hiking", ... },
│ ...
│ ],
│ iceBreakers: [{ text: "...", context: "..." }],
│ bestOpeningLine: { text: "...", whyItWorks: "..." },
│ profileStrengthUsed: ["hiking", "cooking"]
│ }
│
▼
FINAL OUTPUT (HingePipelineOutput)
{
originalProfile: { ... },
analysis: { ... },
improvedBio: { ... },
conversationStarters: { ... },
pipelineMetadata: { totalDuration: 8432, agentCount: 3, timestamp: "..." }
}
8. Why Each Agent Has Its Own System Prompt
A critical design decision: each agent has a dedicated system prompt tailored to its specific responsibility.
┌──────────────────────────────────────────────────────────────────────┐
│ WHY SEPARATE SYSTEM PROMPTS MATTER │
│ │
│ Agent 1 prompt: "You are an expert dating profile ANALYST..." │
│ - Focuses entirely on analysis, scoring, identifying patterns │
│ - Never asked to write or generate — only to evaluate │
│ - Temperature: 0.7 (balanced — needs some creativity in analysis) │
│ │
│ Agent 2 prompt: "You are an expert dating profile bio WRITER..." │
│ - Focuses entirely on rewriting, preserving voice, adding hooks │
│ - Never asked to analyze — receives analysis as input │
│ - Temperature: 0.8 (more creative for writing) │
│ │
│ Agent 3 prompt: "You are an expert at crafting CONVERSATION..." │
│ - Focuses entirely on generating openers and ice breakers │
│ - Never asked to analyze or rewrite — uses improved bio as input │
│ - Temperature: 0.9 (highest creativity for conversation starters) │
│ │
│ KEY INSIGHT: Different temperatures for different creative needs │
└──────────────────────────────────────────────────────────────────────┘
Temperature Strategy
| Agent | Temperature | Reasoning |
|---|---|---|
| Profile Analyzer | 0.7 | Needs analytical consistency but also creative insight |
| Bio Improver | 0.8 | Writing requires more creativity than analysis |
| Conversation Starter Generator | 0.9 | Maximum creativity for diverse, engaging openers |
9. Zod Validation at Each Step — Why It Matters
Every agent output is validated with Zod before being passed to the next agent. Here's what Zod catches:
// Example: What if Agent 1 returns a bad analysis?
// Missing required field
ProfileAnalysisSchema.parse({
strengths: [{ category: "bio", description: "Good length", impactScore: 7 }],
// MISSING: weaknesses, overallScore, profilePersonality, etc.
});
// ZodError: Required at "weaknesses"
// Invalid enum value
ProfileAnalysisSchema.parse({
strengths: [{ category: "appearance", description: "...", impactScore: 7 }],
// "appearance" is not in the enum ["bio", "interests", "photos", "prompts", "overall"]
});
// ZodError: Invalid enum value at "strengths[0].category"
// Out-of-range number
ProfileAnalysisSchema.parse({
overallScore: 15, // max is 10
// ...
});
// ZodError: Number must be less than or equal to 10 at "overallScore"
// String too short
ProfileAnalysisSchema.parse({
profilePersonality: "Nice", // min 20 characters
// ...
});
// ZodError: String must contain at least 20 character(s) at "profilePersonality"
Validation Flow
Agent 1 runs → JSON output → Zod validates
│ │
│ ├── PASS → Continue to Agent 2
│ │
│ └── FAIL → ZodError thrown
│ │
│ ├── Log the error
│ ├── Optionally retry Agent 1
│ └── Pipeline stops (fail fast)
10. Complete Code — Copy and Run
Here is the complete, self-contained code for the entire Hinge Profile Pipeline:
// hinge-pipeline.js
// Complete multi-agent pipeline for dating profile improvement
// Requires: npm install openai zod
import { z } from "zod";
import OpenAI from "openai";
const client = new OpenAI();
// ═══════════════════════════════════════════════════════
// SCHEMAS
// ═══════════════════════════════════════════════════════
const UserProfileSchema = z.object({
name: z.string().min(1),
age: z.number().min(18).max(99),
gender: z.string(),
lookingFor: z.string(),
bio: z.string().min(10),
interests: z.array(z.string()).min(1),
photoDescriptions: z.array(z.string()).optional(),
location: z.string().optional(),
promptAnswers: z.array(z.object({
prompt: z.string(),
answer: z.string(),
})).optional(),
});
const ProfileAnalysisSchema = z.object({
strengths: z.array(z.object({
category: z.enum(["bio", "interests", "photos", "prompts", "overall"]),
description: z.string().min(10),
impactScore: z.number().min(1).max(10),
})).min(1),
weaknesses: z.array(z.object({
category: z.enum(["bio", "interests", "photos", "prompts", "overall"]),
description: z.string().min(10),
severity: z.enum(["low", "medium", "high"]),
suggestion: z.string().min(10),
})).min(1),
overallScore: z.number().min(1).max(10),
profilePersonality: z.string().min(20),
improvementTips: z.array(z.string()).min(1).max(5),
toneAnalysis: z.object({
currentTone: z.string(),
suggestedTone: z.string(),
reasoning: z.string(),
}),
});
const ImprovedBioSchema = z.object({
improvedBio: z.string().min(20).max(500),
changesApplied: z.array(z.object({
changeType: z.enum(["rewrite", "addition", "removal", "restructure", "tone-shift"]),
description: z.string().min(10),
before: z.string(),
after: z.string(),
})).min(1),
improvementScore: z.number().min(1).max(10),
hookCount: z.number().min(0),
conversationStarters: z.array(z.string()).min(1),
preservedElements: z.array(z.string()),
wordCount: z.number(),
});
const ConversationStartersSchema = z.object({
openers: z.array(z.object({
text: z.string().min(10).max(300),
style: z.enum(["playful", "curious", "witty", "sincere", "bold", "thoughtful"]),
targetInterest: z.string(),
conversationDepth: z.enum(["light", "medium", "deep"]),
reasoning: z.string().min(10),
})).min(3).max(7),
iceBreakers: z.array(z.object({
text: z.string().min(10).max(200),
context: z.string(),
})).min(2).max(5),
bestOpeningLine: z.object({
text: z.string(),
whyItWorks: z.string(),
}),
profileStrengthUsed: z.array(z.string()),
});
const HingePipelineOutputSchema = z.object({
originalProfile: UserProfileSchema,
analysis: ProfileAnalysisSchema,
improvedBio: ImprovedBioSchema,
conversationStarters: ConversationStartersSchema,
pipelineMetadata: z.object({
totalDuration: z.number(),
agentCount: z.number(),
timestamp: z.string(),
}),
});
// ═══════════════════════════════════════════════════════
// SYSTEM PROMPTS
// ═══════════════════════════════════════════════════════
const PROFILE_ANALYZER_PROMPT = `You are an expert dating profile analyst. Your job is to analyze a dating profile and identify its strengths, weaknesses, and areas for improvement.
RULES:
1. Be constructive and specific — never vague or mean.
2. Consider what makes profiles attractive: authenticity, specificity, humor, conversation hooks.
3. Analyze the bio, interests, photo descriptions, and prompt answers.
4. Score the profile from 1-10 (10 = exceptional).
5. Identify the personality that comes through (or doesn't).
6. Suggest a tone that would work better if the current tone is weak.
Respond with ONLY valid JSON:
{
"strengths": [{ "category": "bio|interests|photos|prompts|overall", "description": "...", "impactScore": 1-10 }],
"weaknesses": [{ "category": "bio|interests|photos|prompts|overall", "description": "...", "severity": "low|medium|high", "suggestion": "..." }],
"overallScore": 1-10,
"profilePersonality": "...",
"improvementTips": ["..."],
"toneAnalysis": { "currentTone": "...", "suggestedTone": "...", "reasoning": "..." }
}`;
const BIO_IMPROVER_PROMPT = `You are an expert dating profile bio writer. You take a profile analysis and rewrite the user's bio.
RULES:
1. PRESERVE the user's personality and voice.
2. Fix weaknesses identified in the analysis.
3. Add conversation hooks — specific details people can ask about.
4. Keep it concise — dating app bios should be punchy.
5. Include at least 2 conversation hooks.
6. Use the suggested tone from the analysis.
7. Track every change you make.
Respond with ONLY valid JSON:
{
"improvedBio": "...",
"changesApplied": [{ "changeType": "rewrite|addition|removal|restructure|tone-shift", "description": "...", "before": "...", "after": "..." }],
"improvementScore": 1-10,
"hookCount": number,
"conversationStarters": ["..."],
"preservedElements": ["..."],
"wordCount": number
}`;
const CONVERSATION_STARTER_PROMPT = `You are an expert at crafting dating app conversation starters. Generate creative, personalized conversation openers.
RULES:
1. Each opener MUST reference something specific from the bio or interests.
2. Vary styles: playful, curious, witty, sincere, bold, thoughtful.
3. Include openers for different depths (light, medium, deep).
4. Each opener should lead to natural back-and-forth conversation.
5. Never be creepy, overly forward, or use generic pickup lines.
Respond with ONLY valid JSON:
{
"openers": [{ "text": "...", "style": "playful|curious|witty|sincere|bold|thoughtful", "targetInterest": "...", "conversationDepth": "light|medium|deep", "reasoning": "..." }],
"iceBreakers": [{ "text": "...", "context": "..." }],
"bestOpeningLine": { "text": "...", "whyItWorks": "..." },
"profileStrengthUsed": ["..."]
}`;
// ═══════════════════════════════════════════════════════
// AGENT RUNNER (shared utility)
// ═══════════════════════════════════════════════════════
async function callAgent(name, systemPrompt, input, schema, temperature = 0.7) {
console.log(`\n--- ${name} ---`);
const response = await client.chat.completions.create({
model: "gpt-4o",
temperature,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: JSON.stringify(input) },
],
});
const raw = response.choices[0].message.content;
if (!raw) throw new Error(`${name} returned empty response`);
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
const match = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
if (match) {
parsed = JSON.parse(match[1].trim());
} else {
throw new Error(`${name} returned invalid JSON: ${raw.substring(0, 300)}`);
}
}
const validated = schema.parse(parsed);
console.log(`${name}: validated successfully`);
return validated;
}
// ═══════════════════════════════════════════════════════
// PIPELINE
// ═══════════════════════════════════════════════════════
async function runHingeProfilePipeline(rawProfile) {
const start = Date.now();
const profile = UserProfileSchema.parse(rawProfile);
// Agent 1: Analyze
const analysis = await callAgent(
"Profile Analyzer", PROFILE_ANALYZER_PROMPT,
profile, ProfileAnalysisSchema, 0.7
);
// Agent 2: Improve bio (selective context)
const improvedBio = await callAgent(
"Bio Improver", BIO_IMPROVER_PROMPT,
{
originalBio: profile.bio,
interests: profile.interests,
name: profile.name,
analysis: {
weaknesses: analysis.weaknesses,
improvementTips: analysis.improvementTips,
toneAnalysis: analysis.toneAnalysis,
profilePersonality: analysis.profilePersonality,
},
},
ImprovedBioSchema, 0.8
);
// Agent 3: Generate conversation starters
const conversationStarters = await callAgent(
"Conversation Starter Generator", CONVERSATION_STARTER_PROMPT,
{
improvedBio: improvedBio.improvedBio,
conversationHooks: improvedBio.conversationStarters,
interests: profile.interests,
name: profile.name,
lookingFor: profile.lookingFor,
promptAnswers: profile.promptAnswers,
},
ConversationStartersSchema, 0.9
);
const totalDuration = Date.now() - start;
return HingePipelineOutputSchema.parse({
originalProfile: profile,
analysis,
improvedBio,
conversationStarters,
pipelineMetadata: {
totalDuration,
agentCount: 3,
timestamp: new Date().toISOString(),
},
});
}
// ═══════════════════════════════════════════════════════
// MAIN
// ═══════════════════════════════════════════════════════
const sampleProfile = {
name: "Alex",
age: 28,
gender: "Male",
lookingFor: "Serious relationship",
bio: "Hey I'm Alex. I like hiking and cooking. I work in tech. Looking for someone chill to hang out with. I also like watching movies and traveling. Hit me up if you're interested.",
interests: ["Hiking", "Cooking", "Technology", "Movies", "Travel", "Photography"],
photoDescriptions: [
"Selfie at a mountain summit",
"Cooking pasta in a kitchen",
"Group photo at a concert",
"Photo with a dog at a park",
],
location: "San Francisco, CA",
promptAnswers: [
{ prompt: "A life goal of mine", answer: "Visit every continent" },
{ prompt: "My simple pleasures", answer: "Morning coffee and a good book" },
],
};
runHingeProfilePipeline(sampleProfile)
.then(result => {
console.log("\n=== FINAL OUTPUT ===");
console.log(JSON.stringify(result, null, 2));
})
.catch(error => {
console.error("Pipeline failed:", error.message);
});
11. Key Takeaways
- Three specialized agents outperform one monolithic agent for complex tasks like profile improvement — each agent focuses on what it does best.
- Schema contracts (Zod) at every step ensure data integrity and catch problems immediately.
- Selective context means each agent only receives what it needs — reducing tokens and keeping agents focused.
- Different temperatures for different agents — analytical tasks need lower temperature, creative tasks need higher.
- Each agent has its own system prompt tailored to its single responsibility.
- The pipeline assembles a comprehensive final output that includes all intermediate results for transparency.
- The
callAgentutility standardizes JSON parsing, Zod validation, and error reporting across all agents.
Explain-It Challenge
- Why does the Bio Improver (Agent 2) receive the analysis from Agent 1 rather than the raw profile? What would go wrong if it only received the raw profile?
- Explain why the Conversation Starter Generator uses a higher temperature (0.9) than the Profile Analyzer (0.7). What would happen if you swapped them?
- Design a fourth agent for this pipeline: a "Photo Caption Suggester" that generates captions for the user's photos based on the improved bio. Define its Zod schema, system prompt, and where it fits in the pipeline.
Navigation: ← 4.18.a — Multi-Agent Pipeline Design · 4.18.c — ImageKit Direction: SEO Pipeline →