{"name":"AI ad copy workflow","nodes":[{"name":"Every Morning","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-520,300],"parameters":{"rule":{"interval":[{"field":"days","daysInterval":1}]}},"id":"bb000001-1111-4222-8333-444455556601"},{"name":"Fetch Winning Ad Insights","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-280,180],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"level","value":"ad"},{"name":"fields","value":"ad_id,ad_name,campaign_name,spend,impressions,clicks,ctr,cpm,frequency,actions,action_values,purchase_roas"},{"name":"action_attribution_windows","value":"7d_click,1d_view"},{"name":"date_preset","value":"last_30d"},{"name":"filtering","value":"[{\"field\":\"spend\",\"operator\":\"GREATER_THAN\",\"value\":200}]"},{"name":"limit","value":"500"}]},"options":{}},"id":"bb000002-1111-4222-8333-444455556602"},{"name":"Fetch Ad Creative Copy","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-280,420],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/ads","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"id,name,creative{body,title,object_story_spec}"},{"name":"effective_status","value":"[\"ACTIVE\",\"PAUSED\"]"},{"name":"limit","value":"500"}]},"options":{}},"id":"bb000003-1111-4222-8333-444455556603"},{"name":"Merge Metrics With Copy","type":"n8n-nodes-base.merge","typeVersion":3,"position":[-40,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}},"id":"bb000004-1111-4222-8333-444455556604"},{"name":"Rank Winners And Extract Patterns","type":"n8n-nodes-base.code","typeVersion":2,"position":[200,300],"parameters":{"jsCode":"// ---- Grounding config -----------------------------------------------------\n// We only let the LLM imitate copy that actually made money. Everything below\n// is about deciding WHICH copy earned the right to be a template.\nconst CFG = {\n  lookbackDays: 30,\n  minSpend: 200,          // an ad needs real spend before its copy means anything\n  minPurchases: 5,        // and real conversions, not one lucky sale\n  breakevenRoas: 1.8,\n  topN: 8,                // how many winners we feed the model\n  freqCeiling: 3.5        // fatigued winners still teach us copy, but rank lower\n};\n\nconst num = (v) => { const n = typeof v === 'string' ? parseFloat(v) : v; return Number.isFinite(n) ? n : 0; };\nconst pick = (arr, type) => {\n  if (!Array.isArray(arr)) return 0;\n  const hit = arr.find((a) => a.action_type === type);\n  return hit ? num(hit.value) : 0;\n};\n\n// The Graph API returns a paginated envelope ({ data: [...], paging: {} }), so a\n// single HTTP item can hold hundreds of ads. Flatten both shapes into flat rows.\nconst rows = [];\nfor (const item of $input.all()) {\n  const j = item.json || {};\n  if (Array.isArray(j.data)) rows.push(...j.data);\n  else if (Array.isArray(j.ads && j.ads.data)) rows.push(...j.ads.data);\n  else rows.push(j);\n}\n\n// ---- 0. creative lookup ---------------------------------------------------\n// The merge is positional, so a metrics row can be paired with the WRONG\n// creative. Rebuild a map from the /ads side and look copy up by real ad_id.\nconst creativeById = new Map();\nfor (const r of rows) {\n  const c = r.creative || r.adcreatives || null;\n  const id = String(r.id || r.ad_id || '');\n  if (!id || !c) continue;\n  const body = c.body || (c.object_story_spec && c.object_story_spec.link_data && c.object_story_spec.link_data.message) || '';\n  const title = c.title || (c.object_story_spec && c.object_story_spec.link_data && c.object_story_spec.link_data.name) || '';\n  const cta = (c.object_story_spec && c.object_story_spec.link_data && c.object_story_spec.link_data.call_to_action &&\n    c.object_story_spec.link_data.call_to_action.type) || '';\n  if (body || title) creativeById.set(id, { body, title, cta });\n}\n\n// ---- 1. dedupe + compute economics ---------------------------------------\nconst byAd = new Map();\nfor (const r of rows) {\n  const id = String(r.ad_id || r.id || '');\n  if (!id || !r.spend) continue;\n  const prev = byAd.get(id);\n  if (!prev) { byAd.set(id, { ...r }); continue; }\n  prev.spend = num(prev.spend) + num(r.spend);\n  prev.impressions = num(prev.impressions) + num(r.impressions);\n  prev.clicks = num(prev.clicks) + num(r.clicks);\n  prev.actions = [...(prev.actions || []), ...(r.actions || [])];\n  prev.action_values = [...(prev.action_values || []), ...(r.action_values || [])];\n}\n\nconst winners = [];\nfor (const r of byAd.values()) {\n  const adId = String(r.ad_id || r.id || '');\n  const spend = num(r.spend);\n  const impressions = num(r.impressions);\n  const clicks = num(r.clicks);\n  const frequency = num(r.frequency);\n  const purchases = pick(r.actions, 'purchase') || pick(r.actions, 'omni_purchase');\n  const revenue = pick(r.action_values, 'purchase') || pick(r.action_values, 'omni_purchase');\n  const roas = Array.isArray(r.purchase_roas) ? pick(r.purchase_roas, 'purchase') : num(r.purchase_roas);\n  const realRoas = roas || (spend ? revenue / spend : 0);\n  const ctr = impressions ? (clicks / impressions) * 100 : 0;\n  const cpa = purchases ? spend / purchases : null;\n\n  // eligibility: no spend floor = no signal, just noise dressed as insight\n  if (spend < CFG.minSpend) continue;\n  if (purchases < CFG.minPurchases) continue;\n  if (realRoas < CFG.breakevenRoas) continue;\n\n  const copy = creativeById.get(adId);\n  if (!copy || !copy.body || copy.body.length < 25) continue; // nothing to learn from\n\n  // score = profitability x volume confidence, penalised for fatigue\n  const profit = (realRoas - CFG.breakevenRoas) * spend;\n  const confidence = Math.min(1, purchases / 25);\n  const fatiguePenalty = frequency > CFG.freqCeiling ? 0.75 : 1;\n  const score = profit * confidence * fatiguePenalty;\n\n  winners.push({\n    ad_id: adId,\n    ad_name: r.ad_name || '(unnamed)',\n    campaign_name: r.campaign_name || '',\n    body: copy.body, title: copy.title, cta: copy.cta,\n    spend: Math.round(spend), purchases, revenue: Math.round(revenue),\n    roas: Math.round(realRoas * 100) / 100,\n    ctr: Math.round(ctr * 100) / 100,\n    cpa: cpa === null ? null : Math.round(cpa * 100) / 100,\n    frequency: Math.round(frequency * 100) / 100,\n    win_score: Math.round(score)\n  });\n}\n\nwinners.sort((a, b) => b.win_score - a.win_score);\nconst top = winners.slice(0, CFG.topN);\n\n// ---- 2. pattern extraction from the winning copy -------------------------\n// This is the whole point: instead of \"write me an ad\", we tell the model the\n// structural habits that our own profitable ads share.\nconst firstLine = (t) => String(t).split(/\\n|\\.\\s/)[0].trim();\nconst words = (t) => String(t).toLowerCase().match(/[a-z']{3,}/g) || [];\n\nconst STOP = new Set(('the and for you your with that this from have has our are was will can just get all '+\n  'but not out now more than them they what when who why how its it\\'s about into over').split(' '));\n\nconst hookTypes = { question: 0, stat: 0, callout: 0, story: 0, offer: 0 };\nconst freq = new Map();\nlet totalLen = 0, emojiAds = 0, ctaCount = new Map(), priceMentions = 0, socialProof = 0;\n\nfor (const w of top) {\n  const hook = firstLine(w.body);\n  totalLen += w.body.length;\n  if (/^[^?]*\\?/.test(hook)) hookTypes.question++;\n  else if (/\\d+\\s*(%|x|out of|in \\d)/i.test(hook)) hookTypes.stat++;\n  else if (/^(if you|for (every|any|the)|attention|hey |moms|founders|marketers)/i.test(hook)) hookTypes.callout++;\n  else if (/^(i |we |my |our )/i.test(hook)) hookTypes.story++;\n  else hookTypes.offer++;\n\n  if (/[\\u{1F300}-\\u{1FAFF}\\u{2600}-\\u{27BF}]/u.test(w.body)) emojiAds++;\n  if (/\\$\\d|\\d+% off|free shipping/i.test(w.body)) priceMentions++;\n  if (/\\d[\\d,]*\\+? (customers|reviews|people|orders)|rated \\d/i.test(w.body)) socialProof++;\n  if (w.cta) ctaCount.set(w.cta, (ctaCount.get(w.cta) || 0) + 1);\n\n  // weight vocabulary by how much money the ad made, not by raw frequency\n  for (const t of new Set(words(w.body))) {\n    if (STOP.has(t)) continue;\n    freq.set(t, (freq.get(t) || 0) + w.win_score);\n  }\n}\n\nconst topWords = [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15).map((e) => e[0]);\nconst dominantHook = Object.entries(hookTypes).sort((a, b) => b[1] - a[1])[0];\nconst dominantCta = [...ctaCount.entries()].sort((a, b) => b[1] - a[1])[0];\nconst avgLen = top.length ? Math.round(totalLen / top.length) : 0;\nconst blendedRoas = top.length\n  ? Math.round((top.reduce((s, w) => s + w.revenue, 0) / Math.max(1, top.reduce((s, w) => s + w.spend, 0))) * 100) / 100\n  : 0;\n\nreturn [{ json: {\n  winner_count: top.length,\n  winners: top,\n  blended_roas: blendedRoas,\n  patterns: {\n    dominant_hook: dominantHook ? dominantHook[0] : 'offer',\n    hook_mix: hookTypes,\n    avg_body_chars: avgLen,\n    emoji_rate: top.length ? Math.round((emojiAds / top.length) * 100) : 0,\n    price_mention_rate: top.length ? Math.round((priceMentions / top.length) * 100) : 0,\n    social_proof_rate: top.length ? Math.round((socialProof / top.length) * 100) : 0,\n    dominant_cta: dominantCta ? dominantCta[0] : 'SHOP_NOW',\n    money_weighted_vocab: topWords\n  },\n  generated_at: new Date().toISOString()\n} }];"},"id":"bb000005-1111-4222-8333-444455556605"},{"name":"Enough Proven Winners?","type":"n8n-nodes-base.if","typeVersion":2,"position":[440,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"w1","leftValue":"={{ $json.winner_count }}","rightValue":3,"operator":{"type":"number","operation":"gte"}}]},"options":{}},"id":"bb000006-1111-4222-8333-444455556606"},{"name":"Build Creative Brief","type":"n8n-nodes-base.code","typeVersion":2,"position":[680,180],"parameters":{"jsCode":"// Turn the extracted patterns into an explicit, quotable brief. The model\n// never sees raw numbers alone - it sees the RULE the numbers imply.\nconst d = $input.first().json;\nconst p = d.patterns;\nconst w = d.winners;\n\nconst hookGuide = {\n  question: 'open with a question that names the reader\\'s problem',\n  stat: 'open with a concrete number or proof stat',\n  callout: 'open by calling out the exact audience segment',\n  story: 'open in first person with a short personal moment',\n  offer: 'open with the offer / outcome stated flatly'\n};\n\nconst exemplars = w.slice(0, 5).map((x, i) =>\n  (i + 1) + ') [ROAS ' + x.roas + ' on $' + x.spend + ', ' + x.purchases + ' purchases]\\n' +\n  (x.title ? 'HEADLINE: ' + x.title + '\\n' : '') + 'BODY: ' + String(x.body).slice(0, 420)\n).join('\\n\\n');\n\nconst rules = [\n  'Hook: ' + (hookGuide[p.dominant_hook] || hookGuide.offer) + ' (that pattern is ' +\n    p.hook_mix[p.dominant_hook] + ' of our ' + d.winner_count + ' winners).',\n  'Length: aim for ~' + p.avg_body_chars + ' characters, +/- 20%. Our winners are that long for a reason.',\n  p.emoji_rate >= 50 ? 'Emoji: winners use them - keep 1-3, never a wall.' : 'Emoji: winners avoid them - use none.',\n  p.social_proof_rate >= 40 ? 'Include a specific social-proof number in every variant.' : 'Social proof is optional here.',\n  p.price_mention_rate >= 50 ? 'State the price or discount explicitly in the body.' : 'Do not lead with price.',\n  'Close with a CTA consistent with ' + p.dominant_cta + '.',\n  'Reuse this money-weighted vocabulary where it fits naturally: ' + p.money_weighted_vocab.join(', ') + '.'\n];\n\nconst prompt = [\n  'You are writing Meta ad primary text for an account currently running at ' + d.blended_roas + ' blended ROAS.',\n  '',\n  'PROVEN WINNERS (do not copy them verbatim - extract the mechanism):',\n  exemplars,\n  '',\n  'RULES DERIVED FROM PERFORMANCE:',\n  rules.map((r, i) => '- ' + r).join('\\n'),\n  '',\n  'Write 5 NEW primary-text variants. Each must attack a different angle',\n  '(pain, proof, objection, identity, urgency) while obeying the rules above.',\n  '',\n  'Return ONLY JSON: {\"variants\":[{\"angle\":\"\",\"headline\":\"\",\"primary_text\":\"\",\"cta\":\"\"}]}'\n].join('\\n');\n\nreturn [{ json: {\n  brief_source: 'performance-grounded',\n  winner_count: d.winner_count,\n  blended_roas: d.blended_roas,\n  patterns: p,\n  target_chars: p.avg_body_chars,\n  dominant_cta: p.dominant_cta,\n  vocab: p.money_weighted_vocab,\n  prompt\n} }];"},"id":"bb000007-1111-4222-8333-444455556607"},{"name":"Build Cold Start Brief","type":"n8n-nodes-base.code","typeVersion":2,"position":[680,460],"parameters":{"jsCode":"// Not enough profitable copy to learn from yet. Rather than dead-ending, fall\n// back to a category-default brief and clearly mark it as ungrounded so the\n// scorer applies a stricter bar and the Slack post says \"unverified\".\nconst d = $input.first().json;\nconst partial = (d.winners || []).slice(0, 3).map((x) => '- (thin data, ROAS ' + x.roas + ') ' + String(x.body).slice(0, 200));\n\nconst prompt = [\n  'You are writing Meta ad primary text for a direct-response ecommerce account.',\n  'We do NOT yet have enough profitable ads to extract reliable patterns (' +\n    (d.winner_count || 0) + ' qualified winners, need 3+).',\n  partial.length ? 'Weak signal from partial data:\\n' + partial.join('\\n') : 'No usable prior copy.',\n  '',\n  'Fall back to direct-response fundamentals:',\n  '- Hook on a specific problem in the first 8 words.',\n  '- One concrete proof element (number, timeframe, or guarantee).',\n  '- 400-600 characters, plain language, no adjective stacking.',\n  '- One clear CTA.',\n  '',\n  'Write 5 NEW primary-text variants across 5 different angles',\n  '(pain, proof, objection, identity, urgency).',\n  '',\n  'Return ONLY JSON: {\"variants\":[{\"angle\":\"\",\"headline\":\"\",\"primary_text\":\"\",\"cta\":\"\"}]}'\n].join('\\n');\n\nreturn [{ json: {\n  brief_source: 'cold-start',\n  winner_count: d.winner_count || 0,\n  blended_roas: d.blended_roas || 0,\n  target_chars: 500,\n  dominant_cta: 'SHOP_NOW',\n  vocab: [],\n  patterns: (d.patterns || {}),\n  prompt\n} }];"},"id":"bb000008-1111-4222-8333-444455556608"},{"name":"Merge Brief Paths","type":"n8n-nodes-base.merge","typeVersion":3,"position":[920,300],"parameters":{"mode":"append","options":{}},"id":"bb000009-1111-4222-8333-444455556609"},{"name":"Generate Ad Copy Variants","type":"@n8n/n8n-nodes-langchain.openAi","typeVersion":1.8,"position":[1160,300],"parameters":{"modelId":{"__rl":true,"value":"gpt-4o","mode":"list"},"messages":{"values":[{"role":"system","content":"You are a direct response copywriter for performance ecommerce. You imitate the MECHANISM of proven ads, never their wording. You always return strict JSON and nothing else."},{"content":"={{ $json.prompt }}"}]},"options":{}},"id":"bb000010-1111-4222-8333-444455556610"},{"name":"Score Variants Against Winners","type":"n8n-nodes-base.code","typeVersion":2,"position":[1400,300],"parameters":{"jsCode":"// Score every generated variant BEFORE it is allowed near the ad account.\n// The score is literally \"how closely does this match the shape of copy that\n// already made us money\", plus hard compliance checks.\nconst raw = $input.first().json;\nconst briefItem = $('Merge Brief Paths').first().json;\nconst grounded = briefItem.brief_source === 'performance-grounded';\nconst targetChars = Number(briefItem.target_chars) || 500;\nconst vocab = Array.isArray(briefItem.vocab) ? briefItem.vocab : [];\n\n// the LLM node returns the completion in message.content (or content)\nconst text = (raw.message && raw.message.content) || raw.content || raw.text || '';\nlet parsed;\ntry {\n  const m = String(text).match(/\\{[\\s\\S]*\\}/);\n  parsed = JSON.parse(m ? m[0] : String(text));\n} catch (e) {\n  parsed = { variants: [] };\n}\nconst variants = Array.isArray(parsed.variants) ? parsed.variants : [];\n\nconst BANNED = ['revolutionary', 'game-changer', 'game changer', 'unlock the power',\n  'elevate your', 'in today\\'s world', 'look no further', 'cutting-edge', 'seamless'];\nconst CLAIMS = ['cure', 'guaranteed results', 'lose weight fast', 'miracle', '100% safe'];\n\nconst seen = new Set();\nconst out = [];\n\nvariants.forEach((v, idx) => {\n  const body = String(v.primary_text || '').trim();\n  if (!body) return;\n\n  // dedupe near-identical variants (same first 60 chars = same ad)\n  const key = body.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 60);\n  if (seen.has(key)) return;\n  seen.add(key);\n\n  const lower = body.toLowerCase();\n  const flags = [];\n  let score = 50;\n\n  // 1. length fit vs what actually converts on this account\n  const lenRatio = body.length / targetChars;\n  if (lenRatio >= 0.8 && lenRatio <= 1.2) score += 15;\n  else if (lenRatio >= 0.6 && lenRatio <= 1.5) score += 7;\n  else flags.push('length ' + body.length + ' chars off target ' + targetChars);\n\n  // 2. does it reuse the money-weighted vocabulary of our winners\n  const hits = vocab.filter((w) => lower.includes(w));\n  score += Math.min(15, hits.length * 3);\n  if (vocab.length && hits.length === 0) flags.push('shares no vocabulary with winning ads');\n\n  // 3. hook strength - first 8 words must do work\n  const hook = body.split(/\\s+/).slice(0, 8).join(' ');\n  if (/\\?|\\d/.test(hook)) score += 8;\n  if (/^(introducing|welcome to|we are|our product)/i.test(hook)) { score -= 12; flags.push('weak brand-first hook'); }\n\n  // 4. specificity: numbers and concrete proof beat adjectives\n  const numbers = (body.match(/\\d+/g) || []).length;\n  score += Math.min(10, numbers * 4);\n  if (numbers === 0) flags.push('zero specifics - no numbers or proof');\n\n  // 5. slop + compliance\n  const slop = BANNED.filter((b) => lower.includes(b));\n  score -= slop.length * 10;\n  if (slop.length) flags.push('AI slop: ' + slop.join(', '));\n  const risky = CLAIMS.filter((c) => lower.includes(c));\n  score -= risky.length * 25;\n  if (risky.length) flags.push('policy risk: ' + risky.join(', '));\n\n  // 6. one CTA, present\n  if (!v.cta) flags.push('no CTA supplied');\n  else score += 4;\n\n  // ungrounded briefs get a stricter bar - we trust them less by construction\n  const threshold = grounded ? 70 : 80;\n  score = Math.max(0, Math.min(100, Math.round(score)));\n\n  out.push({ json: {\n    variant_id: (briefItem.brief_source === 'cold-start' ? 'cs-' : 'pg-') + Date.now() + '-' + (idx + 1),\n    angle: v.angle || 'unspecified',\n    headline: String(v.headline || '').trim(),\n    primary_text: body,\n    cta: v.cta || briefItem.dominant_cta,\n    char_count: body.length,\n    vocab_hits: hits.length,\n    numbers_used: numbers,\n    brief_source: briefItem.brief_source,\n    grounded_on_winners: briefItem.winner_count,\n    account_blended_roas: briefItem.blended_roas,\n    score,\n    threshold,\n    passes: score >= threshold && risky.length === 0,\n    flags: flags.join(' | ') || 'clean',\n    created_at: new Date().toISOString()\n  } });\n});\n\nout.sort((a, b) => b.json.score - a.json.score);\nreturn out;"},"id":"bb000011-1111-4222-8333-444455556611"},{"name":"Clears The Scoring Bar?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1640,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"p1","leftValue":"={{ $json.passes }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}]},"options":{}},"id":"bb000012-1111-4222-8333-444455556612"},{"name":"Save Approved Copy","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1880,180],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Approved Copy"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}},"id":"bb000013-1111-4222-8333-444455556613"},{"name":"Tag Rejected Copy","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1880,480],"parameters":{"assignments":{"assignments":[{"id":"r1","name":"variant_id","value":"={{ $json.variant_id }}","type":"string"},{"id":"r2","name":"angle","value":"={{ $json.angle }}","type":"string"},{"id":"r3","name":"primary_text","value":"={{ $json.primary_text }}","type":"string"},{"id":"r4","name":"score","value":"={{ $json.score }}","type":"number"},{"id":"r5","name":"threshold","value":"={{ $json.threshold }}","type":"number"},{"id":"r6","name":"rejection_reason","value":"={{ $json.flags }}","type":"string"},{"id":"r7","name":"brief_source","value":"={{ $json.brief_source }}","type":"string"},{"id":"r8","name":"status","value":"rejected","type":"string"}]},"options":{}},"id":"bb000014-1111-4222-8333-444455556614"},{"name":"Log Rejections For Prompt Tuning","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2120,480],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"Rejected Copy"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}},"id":"bb000015-1111-4222-8333-444455556615"},{"name":"Build Copy Report","type":"n8n-nodes-base.code","typeVersion":2,"position":[2120,180],"parameters":{"jsCode":"const rows = $input.all().map((i) => i.json);\nif (!rows.length) {\n  return [{ json: { slack_text: 'No ad copy variants cleared the scoring bar this run.', approved_count: 0 } }];\n}\nconst src = rows[0].brief_source;\nconst avg = Math.round(rows.reduce((s, r) => s + Number(r.score || 0), 0) / rows.length);\nconst lines = rows.slice(0, 5).map((r, i) =>\n  (i + 1) + '. *' + (r.angle || 'variant') + '* — score ' + r.score + '/' + r.threshold +\n  '\\n     ' + String(r.primary_text).slice(0, 180).replace(/\\n/g, ' ') + '…' +\n  '\\n     ↳ ' + r.char_count + ' chars · ' + r.vocab_hits + ' winner-vocab hits · CTA ' + r.cta);\n\nconst slack_text = [\n  '✍️ *' + rows.length + ' new ad copy variants approved* (avg score ' + avg + ')',\n  src === 'performance-grounded'\n    ? 'Grounded on ' + rows[0].grounded_on_winners + ' profitable ads · account blended ROAS ' + rows[0].account_blended_roas\n    : '⚠️ Cold-start brief — not enough profitable copy to learn from, treat as unverified',\n  '',\n  ...lines\n].join('\\n');\n\nreturn [{ json: { approved_count: rows.length, avg_score: avg, brief_source: src, slack_text } }];"},"id":"bb000016-1111-4222-8333-444455556616"},{"name":"Post Copy Batch To Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2360,180],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-creative","mode":"name"},"text":"={{ $json.slack_text }}","otherOptions":{}},"id":"bb000017-1111-4222-8333-444455556617"},{"name":"Note Ground Truth","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-560,20],"parameters":{"content":"## 1. GROUND IT IN MONEY\nLast 30 days of ad-level insights (spend, purchase_roas, frequency) joined to the actual creative body/title. Only ads past $200 spend, 5+ purchases and breakeven ROAS earn the right to be a template.","height":260,"width":440,"color":4},"id":"bb000018-1111-4222-8333-444455556618"},{"name":"Note Patterns","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[420,20],"parameters":{"content":"## 2. PATTERNS → BRIEF\nExtract dominant hook type, target length, emoji/proof/price rates and a money-weighted vocabulary from the winners, then write those as explicit rules in the prompt. Under 3 winners we fall back to a cold-start brief and mark it unverified.","height":260,"width":460,"color":3},"id":"bb000019-1111-4222-8333-444455556619"},{"name":"Note Scoring","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1380,640],"parameters":{"content":"## 3. SCORE BEFORE SAVING\nEvery variant is scored on length fit, winner-vocabulary overlap, hook strength, specificity, AI-slop and policy risk. Grounded briefs pass at 70, cold-start at 80. Rejections are logged so the prompt can be tuned against real failures.","height":240,"width":460,"color":5},"id":"bb000020-1111-4222-8333-444455556620"}],"connections":{"Every Morning":{"main":[[{"node":"Fetch Winning Ad Insights","type":"main","index":0},{"node":"Fetch Ad Creative Copy","type":"main","index":0}]]},"Fetch Winning Ad Insights":{"main":[[{"node":"Merge Metrics With Copy","type":"main","index":0}]]},"Fetch Ad Creative Copy":{"main":[[{"node":"Merge Metrics With Copy","type":"main","index":1}]]},"Merge Metrics With Copy":{"main":[[{"node":"Rank Winners And Extract Patterns","type":"main","index":0}]]},"Rank Winners And Extract Patterns":{"main":[[{"node":"Enough Proven Winners?","type":"main","index":0}]]},"Enough Proven Winners?":{"main":[[{"node":"Build Creative Brief","type":"main","index":0}],[{"node":"Build Cold Start Brief","type":"main","index":0}]]},"Build Creative Brief":{"main":[[{"node":"Merge Brief Paths","type":"main","index":0}]]},"Build Cold Start Brief":{"main":[[{"node":"Merge Brief Paths","type":"main","index":1}]]},"Merge Brief Paths":{"main":[[{"node":"Generate Ad Copy Variants","type":"main","index":0}]]},"Generate Ad Copy Variants":{"main":[[{"node":"Score Variants Against Winners","type":"main","index":0}]]},"Score Variants Against Winners":{"main":[[{"node":"Clears The Scoring Bar?","type":"main","index":0}]]},"Clears The Scoring Bar?":{"main":[[{"node":"Save Approved Copy","type":"main","index":0}],[{"node":"Tag Rejected Copy","type":"main","index":0}]]},"Save Approved Copy":{"main":[[{"node":"Build Copy Report","type":"main","index":0}]]},"Build Copy Report":{"main":[[{"node":"Post Copy Batch To Slack","type":"main","index":0}]]},"Tag Rejected Copy":{"main":[[{"node":"Log Rejections For Prompt Tuning","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}