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

AgentResponsibilityInputOutput
Agent 1: Profile AnalyzerAnalyze the raw profile for strengths, weaknesses, and improvement opportunitiesRaw user profile (name, age, bio, interests, photos description)Analysis object: strengths[], weaknesses[], overallScore, improvementTips[]
Agent 2: Bio ImproverRewrite the bio based on the analysis, keeping the user's voiceAnalysis from Agent 1 + original bioImproved bio, list of changes, improvement score
Agent 3: Conversation Starter GeneratorCreate personalized conversation openers based on the improved profileImproved bio + original interestsArray 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

AgentTemperatureReasoning
Profile Analyzer0.7Needs analytical consistency but also creative insight
Bio Improver0.8Writing requires more creativity than analysis
Conversation Starter Generator0.9Maximum 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

  1. Three specialized agents outperform one monolithic agent for complex tasks like profile improvement — each agent focuses on what it does best.
  2. Schema contracts (Zod) at every step ensure data integrity and catch problems immediately.
  3. Selective context means each agent only receives what it needs — reducing tokens and keeping agents focused.
  4. Different temperatures for different agents — analytical tasks need lower temperature, creative tasks need higher.
  5. Each agent has its own system prompt tailored to its single responsibility.
  6. The pipeline assembles a comprehensive final output that includes all intermediate results for transparency.
  7. The callAgent utility standardizes JSON parsing, Zod validation, and error reporting across all agents.

Explain-It Challenge

  1. 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?
  2. 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?
  3. 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 →