Files
ClaudeMarketing/lib/claude.ts
T
Trey t 807dfc539b feat: add asset preferences, video research, and Remotion ad assets
- Add thumbs-down feedback modal and preference API endpoint
- Add AI UGC video platforms research doc
- Add ReflectAd Remotion composition with public flow assets
- Add gemini-ad-designer and poster-ad-designer pipeline skills
- Add research_reflect_v1.1 pipeline script

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:28:07 -05:00

918 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from "child_process";
import { mkdirSync, writeFileSync, copyFileSync, existsSync } from "fs";
import path from "path";
import { EventEmitter } from "events";
import { prisma } from "./prisma";
import { scanOutputDirectory } from "./scanner";
import { getAllSettings } from "./settings";
export const pipelineEvents = new EventEmitter();
pipelineEvents.setMaxListeners(50);
const AGENT_STEPS = [
"trend-scout",
"marketing-research-agent",
"script-writer",
"gemini-ad-designer",
"poster-ad-designer",
"video-ad-producer",
"copywriter-agent",
"distribution-agent",
] as const;
export type AgentName = (typeof AGENT_STEPS)[number];
export interface AppConfig {
name: string;
slug: string;
primaryColor: string;
accentColor: string;
darkBg: string;
assetsDir: string;
brandIdentity: string | null;
productInfo: string | null;
platformGuidelines: string | null;
stylePreferences: string | null;
}
interface CampaignConfig {
name: string;
platforms: string[];
goal: string;
keyMessage: string;
socialProof?: string;
variations?: number;
useTrendReport?: boolean;
targetAudience?: string;
visualDirection?: string;
competitorApps?: string;
screenshots?: string[];
}
/**
* Build a detailed campaign prompt that gives each agent enough context.
*/
export function buildCampaignPrompt(config: CampaignConfig, appConfig?: AppConfig): string {
const platforms = config.platforms.join(", ");
const variations = config.variations ?? 5;
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const taskName = config.name.replace(/\s+/g, "_").toLowerCase();
const outputDir = `outputs/${taskName}_${dateStr}`;
const appName = appConfig?.name ?? "honeyDue";
const primaryColor = appConfig?.primaryColor ?? "#0079FF";
const accentColor = appConfig?.accentColor ?? "#FF9400";
const knowledgeDir = "_knowledge";
return `# Campaign Brief: "${config.name}"
## App
${appName}
## Goal
${config.goal === "app_downloads" ? "Drive app downloads" : config.goal === "brand_awareness" ? "Build brand awareness" : "Maximize engagement"}
## Target Platforms
${platforms}
## Key Message
${config.keyMessage}
${config.socialProof ? `## Social Proof\n${config.socialProof}` : ""}
${config.targetAudience ? `## Target Audience\n${config.targetAudience}` : ""}
${config.visualDirection ? `## Visual Direction\n${config.visualDirection}` : ""}
${config.competitorApps ? `## Competitor Context\n${config.competitorApps}` : ""}
${config.screenshots?.length ? `## App Screenshots\nThe user provided ${config.screenshots.length} app screenshot(s) to showcase in the ads. These are real screenshots of the app feature being promoted.\nScreenshot files:\n${config.screenshots.map((p) => `- ${p}`).join("\n")}\n\nIMPORTANT: The ad-creative-designer agent MUST incorporate these screenshots into the static ad layouts. Use them as the hero visual — frame them in a device mockup or place them prominently in the ad composition. The screenshots show the actual feature being advertised, so they should be the centerpiece of the ad creative, not the AI-generated imagery.` : ""}
## Brand Colors
- Primary: ${primaryColor}
- Accent: ${accentColor}
## Content Requirements
- ${variations} hook variations per platform
- Static ads at exact platform dimensions (see ${knowledgeDir}/platform_guidelines.md)
- Video ads with platform-appropriate styles:
- Instagram: "polished" style — clean motion graphics
- TikTok: "authentic" style — raw, native feel
- Nextdoor: "local" style — warm, community-focused
- Platform-tuned captions with hashtags (except Nextdoor)
## Output Directory
All outputs MUST go to: ${outputDir}/
- ${outputDir}/ads/ — static PNG images
- ${outputDir}/scripts/ — ad scripts JSON + summary
- ${outputDir}/video/ — rendered MP4 files
- ${outputDir}/copy/ — platform caption JSON files
- ${outputDir}/trend_report.json — trend analysis
- ${outputDir}/research_results.json — research data
- ${outputDir}/research_brief.md — research summary
- ${outputDir}/Publish_${taskName}_${dateStr}.md — publish manifest
${config.useTrendReport ? "## Use Latest Trends\nCheck outputs/ for the most recent trend_report.json and use those hooks as inspiration.\n" : ""}
## Instructions
Read CLAUDE.md first. Then execute each agent skill in order:
1. Read knowledge files (${knowledgeDir}/brand_identity.md, ${knowledgeDir}/platform_guidelines.md, ${knowledgeDir}/product_campaign.md)
2. Run trend-scout skill
3. Run marketing-research-agent skill
4. Run script-writer skill
5. Run ad-creative-designer skill
6. Run video-ad-producer skill
7. Run copywriter-agent skill
8. Run distribution-agent skill
CRITICAL: Read each skill's SKILL.md before executing. Follow the skill instructions exactly.`;
}
/**
* Build a style preferences prompt section from app config.
* Groups liked references by style tag (with-people / without-people) set at
* generation time so the correct reference images are used for matching ads.
* Disliked reasons are text-only and universal.
*/
function buildStylePreferencesSection(appConfig?: AppConfig): string {
if (!appConfig?.stylePreferences) return "";
interface PrefEntry { filePath: string; fileName: string; style?: string | null }
interface DislikeEntry { reason: string; fileName: string; style?: string | null }
let prefs: { liked?: PrefEntry[]; disliked?: DislikeEntry[] };
try {
prefs = JSON.parse(appConfig.stylePreferences);
} catch {
return "";
}
const liked = prefs.liked || [];
const disliked = prefs.disliked || [];
if (liked.length === 0 && disliked.length === 0) return "";
const withPeople = liked.filter((e) => e.style === "with-people");
const withoutPeople = liked.filter((e) => e.style === "without-people");
const untagged = liked.filter((e) => !e.style);
const lines: string[] = ["## Style Preferences (learned from user feedback)", ""];
if (withPeople.length > 0) {
lines.push("### DO — liked WITH-PEOPLE ads (use as reference_images ONLY for ads with people):");
for (const entry of withPeople) {
lines.push(`- "${entry.fileName}"`);
}
const refs = withPeople.slice(-2);
lines.push("Reference image paths:");
for (const entry of refs) {
lines.push(`- ${entry.filePath}`);
}
lines.push("");
}
if (withoutPeople.length > 0) {
lines.push("### DO — liked WITHOUT-PEOPLE ads (use as reference_images ONLY for product-focused ads):");
for (const entry of withoutPeople) {
lines.push(`- "${entry.fileName}"`);
}
const refs = withoutPeople.slice(-2);
lines.push("Reference image paths:");
for (const entry of refs) {
lines.push(`- ${entry.filePath}`);
}
lines.push("");
}
if (untagged.length > 0) {
lines.push("### DO — liked ads (general inspiration, use for any ad type):");
for (const entry of untagged) {
lines.push(`- "${entry.fileName}"`);
}
const refs = untagged.slice(-2);
lines.push("Reference image paths:");
for (const entry of refs) {
lines.push(`- ${entry.filePath}`);
}
lines.push("");
}
if (disliked.length > 0) {
lines.push("### DON'T — styles the user dislikes (applies to ALL ad types):");
for (const entry of disliked) {
lines.push(`- Avoid: "${entry.reason}" (from "${entry.fileName}")`);
}
lines.push("");
}
return lines.join("\n");
}
/**
* Build a focused prompt for a single agent step.
*/
function buildAgentPrompt(
agentName: string,
campaignBrief: string,
outputDir: string,
appConfig?: AppConfig,
screenshots?: string[]
): string {
const appName = appConfig?.name ?? "honeyDue";
const primaryColor = appConfig?.primaryColor ?? "#0079FF";
const accentColor = appConfig?.accentColor ?? "#FF9400";
const assetsDir = appConfig?.assetsDir ?? "assets";
const knowledgeDir = "_knowledge";
const agentInstructions: Record<string, string> = {
"trend-scout": `You are the Trend Scout agent.
Read and follow the skill instructions in skills/trend-scout/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Then execute the Tavily research queries described in the skill.
Use the Bash tool to run: npx tavily search "your query here" OR write a Node.js script using @tavily/core.
Save your output to: ${outputDir}/trend_report.json
${campaignBrief}`,
"marketing-research-agent": `You are the Marketing Research Agent.
Read and follow the skill instructions in skills/marketing-research-agent/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the trend report from: ${outputDir}/trend_report.json (if it exists)
Then execute 5 deep Tavily research queries. Write a Node.js script using @tavily/core to run the searches.
Save outputs to:
- ${outputDir}/research_results.json
- ${outputDir}/research_brief.md
- ${outputDir}/interactive_report.html
${campaignBrief}`,
"script-writer": `You are the Script Writer agent.
Read and follow the skill instructions in skills/script-writer/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the upstream outputs:
- ${outputDir}/trend_report.json
- ${outputDir}/research_results.json
- ${outputDir}/research_brief.md
Write ad scripts with hook-body-CTA structure, timed for video.
Save outputs to: ${outputDir}/scripts/
- ${outputDir}/scripts/scripts_all.json
- ${outputDir}/scripts/scripts_summary.md
- Individual script files per platform
${campaignBrief}`,
"gemini-ad-designer": `You are the Gemini Ad Designer agent.
Read and follow the skill instructions in skills/gemini-ad-designer/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the upstream outputs:
- ${outputDir}/scripts/scripts_all.json
- ${outputDir}/research_brief.md
## Gemini AI-Generated Ads (NanoBanana MCP)
Use the NanoBanana MCP tools to create polished ad images.${screenshots?.length ? `
CRITICAL — REFERENCE IMAGES:
The user provided real app screenshots that MUST be used as reference images in every generate call.
Screenshot absolute paths:
${screenshots.map((p) => `\${CWD}/${p}`).join("\n")}
Where \${CWD} is your current working directory (use pwd to get the absolute path).` : ""}
APP ICON: The ${appName} app icon is at ${assetsDir}/icon.png. This icon MUST be visible in every ad — include it as a reference_image alongside the screenshot, and instruct Gemini to place the ${appName} icon prominently near the branding/CTA area of every ad.
For EACH ad, follow this EXACT sequence:
1. First call mcp__nanobanana__set_aspect_ratio to set the correct ratio for the platform (1:1 for feed, 9:16 for stories/reels/tiktok)
2. Then call mcp__nanobanana__gemini_generate_image with:
- prompt: A detailed description of the ad layout, headline text, brand colors (${primaryColor} blue, ${accentColor} orange), and style${screenshots?.length ? `
- reference_images: ["/absolute/path/to/screenshot.png"] — use the REAL app screenshot so Gemini incorporates the actual UI, NOT a made-up version. The prompt should say "Use the provided reference image as the app screenshot shown on the phone screen in the ad. Do NOT change or recreate the app UI — use it exactly as provided."` : ""}
- output_path: the destination file path
3. Save to ${outputDir}/ads/gemini/
4. Name files: gemini_{platform}_{hook}_{dimensions}.png
Generate exactly 4 Gemini ads with this mix:
- 2 WITHOUT people (product-focused, phone mockup + environment/abstract):
- 1x Instagram Feed 1080x1080
- 1x TikTok 1080x1920
- 2 WITH people (lifestyle, person interacting with the app):
- 1x Instagram Stories 1080x1920
- 1x TikTok 1080x1920
IMPORTANT: For ads WITH people, show real-looking people naturally using the app — not stock photo poses. For ads WITHOUT people, focus on the phone/app in an environment (floating over house, on a counter, etc.)
Platform dimensions:
- Instagram Feed: 1080x1080
- Instagram Stories: 1080x1920
- Nextdoor Spotlight: 1200x1200
- Nextdoor Display: 1200x628
Save ${outputDir}/ads/gemini/manifest.json listing all generated Gemini ads with fields: fileName, set ("gemini"), hook, platform, dimensions, headline, style ("with-people" or "without-people").
${buildStylePreferencesSection(appConfig)}
${campaignBrief}`,
"poster-ad-designer": `You are the Poster Ad Designer agent.
Read and follow the skill instructions in skills/poster-ad-designer/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the upstream outputs:
- ${outputDir}/scripts/scripts_all.json
- ${outputDir}/research_brief.md
## Canvas Design Posters (Museum-quality art)
Create poster ads using the /skill canvas-design approach. This is a TWO-STEP process:
### Step A: Design Philosophy
First, create a visual design philosophy (.md file) for this campaign's poster aesthetic. Save it to ${outputDir}/ads/posters/design_philosophy.md
The philosophy should:
- Name the aesthetic movement (1-2 words, e.g. "Domestic Geometry" or "Maintenance Modernism")
- Be 4-6 paragraphs articulating how the philosophy manifests through space, form, color, scale, rhythm, composition
- Emphasize: visual expression over text, spatial communication, artistic interpretation, minimal words
- Stress meticulous craftsmanship — the final work must appear as though someone at the top of their field labored over every detail
- Draw from the campaign's soul
- Brand palette as foundation: ${primaryColor}, ${accentColor}, warm off-white, dark navy
### Step B: Express the Philosophy as Poster Art
Using the philosophy, create each poster as a .png file. For each:
1. Write a Python or Node.js script that generates the poster programmatically (using canvas/PDF libraries, or build HTML and screenshot with Playwright)
2. Treat each poster as an ART OBJECT — 90% visual design, 10% essential text
3. Use repeating patterns, perfect geometric shapes, systematic visual language
4. Typography is minimal and design-forward — sparse labels, bold single phrases, never paragraphs
5. The campaign's hook text appears as a visual accent, not a headline block
6. ${screenshots?.length ? `Incorporate the app screenshot (${screenshots.join(", ")}) inside the phone frame image at ${assetsDir}/phone.png — layer the screenshot BEHIND the phone frame PNG (which has a transparent screen area and an orange rim with dynamic island). The screenshot and phone frame dynamic islands must align. This creates a realistic device mockup. Treat this composite as part of the art, not just dropped in.` : "Use abstract visual representations of the app concept"}
7. Every element contained within canvas boundaries with proper margins — nothing overlaps, everything breathes
8. The result should look like it could hang in a gallery or appear in a design magazine
9. The ${appName} app icon (${assetsDir}/icon.png) MUST appear in every poster, placed near the branding or CTA area. Use it as an <img> element in the HTML.
### CRITICAL Layout Rule: Phone Must NOT Cover Text
The phone mockup and text MUST occupy separate vertical zones — NEVER overlapping.
Use a three-zone vertical layout:
- **Top zone (15-30%):** Headline text only. No phone.
- **Middle zone (40-55%):** Phone mockup, centered. No text overlapping.
- **Bottom zone (15-25%):** Subtext, CTA, branding. No phone.
For 9:16 (1080x1920): headline top ~380px, phone middle ~420-1400px, CTA bottom ~400px.
For 1:1 (1080x1080): headline top ~200px, phone center max 45% width/500px tall, CTA bottom ~250px.
Before rendering, verify bounding boxes don't overlap. If they do, shrink the phone.
### MANDATORY Typography & Sizing Rules (Social Media Readability)
These are viewed on phones at arm's length. Text that looks fine on a monitor is INVISIBLE in a feed.
- **Minimum font size: 44px** — NO text below 44px, ever. Clamp anything smaller up to 44px.
- **Text scale multiplier: 1.13x** — apply 113% to all font sizes before rendering to compensate for viewing distance.
- **Hero headline: 75-100px effective** (66-88px raw × 1.13)
- **Body/subheadline: 44px minimum** (even if design calls for smaller)
- **Stat labels, CTA secondary text: 44px minimum**
- **Brand name: ~50px effective** (44px × 1.13)
- **Minimum gap between stacked text:**
- Giant (100-140px) above + Large below: 50px gap
- Large above + Medium below: 40px gap
- Medium + Medium: 30px gap
- Absolute minimum gap: 20px
- **2x internal render recommended:** Render at 2160×3840 (for 9:16) or 2160×2160 (for 1:1), downsample to 1080 for export — produces cleaner anti-aliasing on text and curves.
- **Export as JPEG quality=95, subsampling=0** (4:4:4 chroma) — NOT PNG. Instagram converts PNG to JPEG anyway.
- **CTA block at fixed bottom position:** CTA_BOTTOM_MARGIN = 60px from bottom edge. All visual content centered between header bottom and CTA top.
- **Overflow check:** Always calculate total vertical extent before rendering. If content exceeds canvas, reduce phone width first (most compressible element).
Save posters to ${outputDir}/ads/posters/
Name files: poster_{platform}_{hook}_{dimensions}.png
Generate at least 4 posters:
- 2x Instagram (1 feed 1080x1080, 1 stories 1080x1920)
- 2x TikTok cover images (1080x1920)
Platform dimensions:
- Instagram Feed: 1080x1080
- Instagram Stories: 1080x1920
- Nextdoor Spotlight: 1200x1200
- Nextdoor Display: 1200x628
Save ${outputDir}/ads/posters/manifest.json listing all generated poster ads with fields: fileName, set ("poster"), hook, platform, dimensions, headline.
After generating posters, also create the combined ${outputDir}/ads/ad_manifest.json listing ALL ads from both sets (read ${outputDir}/ads/gemini/manifest.json for the Gemini ads). Fields: fileName, set ("gemini" or "poster"), hook, platform, dimensions, headline, style.
${buildStylePreferencesSection(appConfig)}
${campaignBrief}`,
"video-ad-producer": `You are the Video Ad Producer agent.
Read and follow the skill instructions in skills/video-ad-producer/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the upstream outputs:
- ${outputDir}/scripts/scripts_all.json
- ${outputDir}/ads/ad_manifest.json
- ${outputDir}/ads/gemini/manifest.json
- ${outputDir}/ads/posters/manifest.json
## Phone Frame Asset
A real iPhone frame PNG is at: ${assetsDir}/phone.png (orange rim, dynamic island, transparent screen)
Also available in remotion-ad/public/phone.png for use as a Remotion staticFile.
${screenshots?.length ? `
## App Screenshots
Screenshot files: ${screenshots.map((p) => `${p}`).join(", ")}
Also available in remotion-ad/public/tasks_overdue.png
For phone mockup scenes: layer the screenshot BEHIND phone.png so the dynamic islands align. The phone frame has a transparent screen area.
` : ""}
## Video Creation Rules
Create ONE video for EACH static ad in BOTH the Gemini and Canvas Poster sets. Read both manifests to get the full list.
Each video MUST match its corresponding static ad:
- Same hook text, same message, same CTA
- Same tone (if the ad is moody/dark, the video is moody/dark; if clean/minimal, the video is clean/minimal)
- The static ad image can appear as a scene in the video (e.g., the final frame)
For each video:
1. Create a Remotion composition in remotion-ad/src/ with a unique CompositionId
2. Use the phone.png frame + screenshot for the phone reveal scene
3. Structure: Hook text (2-3s) → Phone reveal with app screenshot (4-6s) → Social proof (2-3s) → CTA (2-3s)
4. Render using: cd remotion-ad && npx remotion render src/index.ts CompositionId --output ../${outputDir}/video/filename.mp4
Video specs:
- Instagram Reels: 1080x1920, 15s, 30fps
- TikTok: 1080x1920, 12s, 30fps
Naming: video_{source}_{platform}_{hook}_{dimensions}.mp4
Where source is "gemini" or "poster"
Save scene_plans.json and all MP4s to: ${outputDir}/video/
${campaignBrief}`,
"copywriter-agent": `You are the Copywriter agent.
Read and follow the skill instructions in skills/copywriter-agent/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the upstream outputs:
- ${outputDir}/research_results.json
- ${outputDir}/scripts/scripts_all.json
- ${outputDir}/ads/ad_manifest.json
Write platform-tuned captions following the Hook → Value → CTA → Hashtags structure.
Save outputs to:
- ${outputDir}/copy/instagram_captions.json
- ${outputDir}/copy/tiktok_captions.json
- ${outputDir}/copy/nextdoor_posts.json
- ${outputDir}/copy/copy_matrix.json (maps captions to creatives)
${campaignBrief}`,
"distribution-agent": `You are the Distribution Agent.
Read and follow the skill instructions in skills/distribution-agent/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
Gather ALL outputs from the campaign:
- ${outputDir}/ads/ (all PNG files)
- ${outputDir}/video/ (all MP4 files)
- ${outputDir}/copy/ (all caption JSON files)
- ${outputDir}/scripts/ (scripts)
Create a publish manifest at: ${outputDir}/Publish_manifest.md
The manifest should include:
- List of all assets with file paths
- Recommended caption for each asset
- Recommended posting schedule
- Platform-specific notes
- A review checklist
IMPORTANT: Do NOT publish anything. Only create the manifest for human review.
${campaignBrief}`,
};
return agentInstructions[agentName] || `Execute the ${agentName} skill. ${campaignBrief}`;
}
const AGENT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes per agent
const AGENT_LABELS: Record<string, string> = {
"trend-scout": "Trend Scout",
"marketing-research-agent": "Research Agent",
"script-writer": "Script Writer",
"gemini-ad-designer": "Gemini Ad Designer",
"poster-ad-designer": "Poster Ad Designer",
"video-ad-producer": "Video Ad Producer",
"copywriter-agent": "Copywriter",
"distribution-agent": "Distribution Agent",
};
function humanizeAgentError(agentName: string, code: number | null, stderr: string): string {
const label = AGENT_LABELS[agentName] || agentName;
// Signal-based exits
if (code === 143 || code === 137) {
return `${label} was stopped (process terminated)`;
}
if (code === 130) {
return `${label} was interrupted`;
}
// Try to extract a meaningful error from stderr, ignoring noise
const meaningful = stderr
.split("\n")
.filter((line) => {
const l = line.trim().toLowerCase();
if (!l) return false;
if (l.includes("warning:")) return false;
if (l.includes("stdin")) return false;
if (l.includes("piping from")) return false;
if (l.includes("deprecation")) return false;
return true;
})
.slice(-3)
.join(" ")
.trim();
if (meaningful) {
// Cap length and clean up
const cleaned = meaningful.slice(0, 200).replace(/\s+/g, " ");
return `${label} failed: ${cleaned}`;
}
// Generic fallback
if (code !== null && code !== 0) {
return `${label} failed (exit code ${code})`;
}
return `${label} failed unexpectedly`;
}
const AGENT_TOOLS: Record<string, string> = {
"gemini-ad-designer":
"Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image,mcp__nanobanana__set_aspect_ratio",
};
const DEFAULT_TOOLS =
"Read,Edit,Write,Bash,Grep,Glob";
export async function runAgentStep(
agentName: string,
prompt: string,
cwd: string,
env: Record<string, string>
): Promise<{ output: string }> {
return new Promise((resolve, reject) => {
const allowedTools = AGENT_TOOLS[agentName] || DEFAULT_TOOLS;
const args = [
"-p",
prompt,
"--output-format",
"stream-json",
"--verbose",
"--allowedTools",
allowedTools,
];
const claude = spawn("claude", args, {
cwd,
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
// Timeout watchdog
const timeout = setTimeout(() => {
claude.kill("SIGTERM");
reject(new Error(`${agentName} timed out after ${AGENT_TIMEOUT_MS / 60000} minutes`));
}, AGENT_TIMEOUT_MS);
claude.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
claude.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
claude.on("close", (code) => {
clearTimeout(timeout);
if (code === 0) {
// Extract meaningful text from stream-json stdout
let summary = "";
const lines = stdout.split("\n").filter(Boolean);
for (const line of lines) {
try {
const event = JSON.parse(line);
if (event.result?.text) summary = event.result.text;
else if (event.type === "text" && event.text) summary += event.text;
} catch {
// skip non-JSON lines
}
}
resolve({ output: summary || stdout.slice(-1000) });
} else {
reject(new Error(humanizeAgentError(agentName, code, stderr)));
}
});
claude.on("error", (err) => {
clearTimeout(timeout);
reject(new Error(`Failed to start ${agentName}: ${err.message}`));
});
});
}
/**
* Load API keys from settings DB to pass to Claude subprocess environment.
*/
async function loadPipelineEnv(): Promise<Record<string, string>> {
const settings = await getAllSettings();
const env: Record<string, string> = {};
if (settings.TAVILY_API_KEY) env.TAVILY_API_KEY = settings.TAVILY_API_KEY;
if (settings.POSTIZ_URL) env.POSTIZ_URL = settings.POSTIZ_URL;
if (settings.POSTIZ_API_KEY) env.POSTIZ_API_KEY = settings.POSTIZ_API_KEY;
if (settings.GEMINI_API_KEY) env.GEMINI_API_KEY = settings.GEMINI_API_KEY;
if (settings.NEXTDOOR_API_TOKEN) env.NEXTDOOR_API_TOKEN = settings.NEXTDOOR_API_TOKEN;
if (settings.NEXTDOOR_ADVERTISER_ID) env.NEXTDOOR_ADVERTISER_ID = settings.NEXTDOOR_ADVERTISER_ID;
return env;
}
/**
* Write the active app's knowledge files to pipeline/_knowledge/ for agents to read.
*/
function writeKnowledgeFiles(pipelineRoot: string, appConfig: AppConfig) {
const knowledgeDir = path.join(pipelineRoot, "_knowledge");
mkdirSync(knowledgeDir, { recursive: true });
if (appConfig.brandIdentity) {
writeFileSync(path.join(knowledgeDir, "brand_identity.md"), appConfig.brandIdentity, "utf-8");
}
if (appConfig.productInfo) {
writeFileSync(path.join(knowledgeDir, "product_campaign.md"), appConfig.productInfo, "utf-8");
}
if (appConfig.platformGuidelines) {
writeFileSync(path.join(knowledgeDir, "platform_guidelines.md"), appConfig.platformGuidelines, "utf-8");
}
}
/**
* Copy app's icon and phone frame to remotion-ad/public/ for video rendering.
*/
function copyAppAssetsToRemotion(pipelineRoot: string, appConfig: AppConfig) {
const remotionPublic = path.join(pipelineRoot, "remotion-ad", "public");
mkdirSync(remotionPublic, { recursive: true });
const iconSrc = path.join(pipelineRoot, appConfig.assetsDir, "icon.png");
const phoneSrc = path.join(pipelineRoot, appConfig.assetsDir, "phone.png");
if (existsSync(iconSrc)) {
copyFileSync(iconSrc, path.join(remotionPublic, "icon.png"));
}
if (existsSync(phoneSrc)) {
copyFileSync(phoneSrc, path.join(remotionPublic, "phone.png"));
}
}
export async function launchPipeline(
campaignId: string,
prompt: string,
cwd: string,
appConfig?: AppConfig
) {
// Load API keys from settings
const pipelineEnv = await loadPipelineEnv();
// Get campaign for output path and config
const campaignData = await prisma.campaign.findUnique({ where: { id: campaignId } });
const outputDir = campaignData?.outputPath || "outputs/default";
const campaignConfig = campaignData?.config ? JSON.parse(campaignData.config) : {};
const screenshots: string[] = campaignConfig.screenshots || [];
// Write knowledge files and copy assets for the active app
if (appConfig) {
writeKnowledgeFiles(cwd, appConfig);
copyAppAssetsToRemotion(cwd, appConfig);
}
// Create output directories
const dirs = ["ads", "scripts", "video", "copy"];
for (const dir of dirs) {
mkdirSync(path.join(cwd, outputDir, dir), { recursive: true });
}
await prisma.campaign.update({
where: { id: campaignId },
data: { status: "running" },
});
pipelineEvents.emit(campaignId, {
type: "pipeline_started",
campaignId,
});
for (const agentName of AGENT_STEPS) {
const agentRun = await prisma.agentRun.create({
data: {
campaignId,
agentName,
status: "running",
startedAt: new Date(),
},
});
pipelineEvents.emit(campaignId, {
type: "agent_started",
agentName,
agentRunId: agentRun.id,
});
const startTime = Date.now();
try {
const agentPrompt = buildAgentPrompt(agentName, prompt, outputDir, appConfig, screenshots);
const { output } = await runAgentStep(agentName, agentPrompt, cwd, pipelineEnv);
const durationMs = Date.now() - startTime;
await prisma.agentRun.update({
where: { id: agentRun.id },
data: {
status: "completed",
completedAt: new Date(),
durationMs,
outputSummary: output.slice(0, 500),
outputPath: outputDir,
},
});
pipelineEvents.emit(campaignId, {
type: "agent_completed",
agentName,
agentRunId: agentRun.id,
durationMs,
outputSummary: output.slice(0, 200),
});
} catch (error) {
const durationMs = Date.now() - startTime;
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
await prisma.agentRun.update({
where: { id: agentRun.id },
data: {
status: "failed",
completedAt: new Date(),
durationMs,
error: errorMessage,
},
});
pipelineEvents.emit(campaignId, {
type: "agent_failed",
agentName,
agentRunId: agentRun.id,
error: errorMessage.slice(0, 200),
});
// Continue to next agent even if one fails
}
}
// Scan output directory for generated assets
if (campaignData?.outputPath) {
try {
const result = await scanOutputDirectory(campaignId, campaignData.outputPath, cwd);
pipelineEvents.emit(campaignId, {
type: "scan_complete",
scanned: result.scanned,
created: result.created,
});
} catch {
// Scanner errors shouldn't block pipeline completion
}
}
await prisma.campaign.update({
where: { id: campaignId },
data: { status: "review" },
});
pipelineEvents.emit(campaignId, {
type: "pipeline_complete",
status: "review",
});
}
export async function sendChatMessage(
sessionId: string | null,
message: string,
cwd: string
): Promise<ReadableStream<Uint8Array>> {
const pipelineEnv = await loadPipelineEnv();
const args = [
"-p",
message,
"--output-format",
"stream-json",
"--verbose",
"--allowedTools",
"Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image,mcp__nanobanana__set_aspect_ratio",
];
if (sessionId) args.push("--resume", sessionId);
const claude = spawn("claude", args, {
cwd,
env: { ...process.env, ...pipelineEnv },
});
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
claude.stdout.on("data", (chunk: Buffer) => {
const lines = chunk.toString().split("\n").filter(Boolean);
for (const line of lines) {
try {
const event = JSON.parse(line);
// Handle various Claude CLI stream-json event types
let text = "";
if (event.type === "content_block_delta" && event.delta?.text) {
text = event.delta.text;
} else if (event.type === "assistant" && event.message) {
text = event.message;
} else if (event.type === "text" && event.text) {
text = event.text;
} else if (event.result?.text) {
text = event.result.text;
}
if (text) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ text })}\n\n`)
);
}
// Capture session ID if provided
if (event.session_id || event.sessionId) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ sessionId: event.session_id || event.sessionId })}\n\n`
)
);
}
} catch {
// Non-JSON line — skip (likely progress output)
}
}
});
claude.on("close", () => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`)
);
controller.close();
});
claude.on("error", () => {
controller.close();
});
},
});
}