{"name":"YouTube ads scale workflow","nodes":[{"id":"a1b2c3d4-0001-4a11-8b01-000000000001","name":"Every Morning 7am","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-460,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":24}]}}},{"id":"a1b2c3d4-0002-4a11-8b01-000000000002","name":"Pull YouTube Ad Performance","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-200,300],"parameters":{"method":"POST","url":"https://googleads.googleapis.com/v18/customers/CUSTOMER_ID/googleAds:searchStream","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ query: \"SELECT campaign.id, campaign.name, campaign.resource_name, campaign_budget.resource_name, campaign_budget.amount_micros, ad_group.id, ad_group.name, ad_group_ad.resource_name, ad_group_ad.status, ad_group_ad.ad.id, ad_group_ad.ad.name, ad_group_ad.ad.video_responsive_ad.headlines, metrics.cost_micros, metrics.impressions, metrics.video_views, metrics.video_view_rate, metrics.video_quartile_p25_rate, metrics.video_quartile_p50_rate, metrics.video_quartile_p100_rate, metrics.conversions, metrics.conversions_value FROM ad_group_ad WHERE campaign.advertising_channel_type = 'VIDEO' AND ad_group_ad.status = 'ENABLED' AND segments.date DURING LAST_7_DAYS\" }) }}","options":{}}},{"id":"a1b2c3d4-0003-4a11-8b01-000000000003","name":"Normalize Video Metrics","type":"n8n-nodes-base.code","typeVersion":2,"position":[60,300],"parameters":{"jsCode":"// Google Ads searchStream returns [{ results: [...] }] pages.\n// Flatten them into one row per video ad and derive the media-buyer metrics.\nconst pages = $input.all().map(i => i.json);\nconst results = [];\nfor (const p of pages) {\n  const batch = p.results || (p.body && p.body.results) || [];\n  for (const r of batch) results.push(r);\n}\n\nconst rows = results.map((r) => {\n  const m = r.metrics || {};\n  const ad = (r.adGroupAd && r.adGroupAd.ad) || {};\n  const video = (ad.videoResponsiveAd || {});\n  const cost = Number(m.costMicros || 0) / 1e6;\n  const impressions = Number(m.impressions || 0);\n  const views = Number(m.videoViews || 0);\n  const conversions = Number(m.conversions || 0);\n  const convValue = Number(m.conversionsValue || 0);\n\n  // Google returns quartile rates as 0..1 fractions\n  const p25 = Number(m.videoQuartileP25Rate || 0);\n  const p50 = Number(m.videoQuartileP50Rate || 0);\n  const p100 = Number(m.videoQuartileP100Rate || 0);\n  const viewRate = impressions > 0 ? views / impressions : 0;\n\n  // Hook label = first headline of the video ad, normalised for dedupe\n  const rawHook = (video.headlines && video.headlines[0] && video.headlines[0].text)\n    || ad.name || 'untitled hook';\n  const hookKey = String(rawHook).toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\\s+/g, ' ').trim();\n\n  return {\n    customer_id: (r.customer && r.customer.id) || 'CUSTOMER_ID',\n    campaign_id: (r.campaign && r.campaign.id) || null,\n    campaign_name: (r.campaign && r.campaign.name) || 'unknown campaign',\n    campaign_resource: (r.campaign && r.campaign.resourceName) || null,\n    ad_group_id: (r.adGroup && r.adGroup.id) || null,\n    ad_group_name: (r.adGroup && r.adGroup.name) || 'unknown ad group',\n    ad_id: ad.id || null,\n    ad_resource: (r.adGroupAd && r.adGroupAd.resourceName) || null,\n    ad_status: (r.adGroupAd && r.adGroupAd.status) || 'UNKNOWN',\n    hook: String(rawHook),\n    hook_key: hookKey,\n    daily_budget: Number((r.campaignBudget && r.campaignBudget.amountMicros) || 0) / 1e6,\n    budget_resource: (r.campaignBudget && r.campaignBudget.resourceName) || null,\n    spend: Number(cost.toFixed(2)),\n    impressions,\n    views,\n    conversions: Number(conversions.toFixed(2)),\n    conversions_value: Number(convValue.toFixed(2)),\n    view_rate: Number(viewRate.toFixed(4)),\n    // first-5-second retention proxy: skippable in-streams only count a view at 0:05/0:30,\n    // so p25 completion on a <=20s asset is the closest signal to \"they stayed past the hook\"\n    hook_retention: Number(p25.toFixed(4)),\n    p50_rate: Number(p50.toFixed(4)),\n    p100_rate: Number(p100.toFixed(4)),\n    cpv: views > 0 ? Number((cost / views).toFixed(4)) : null,\n    cpa: conversions > 0 ? Number((cost / conversions).toFixed(2)) : null,\n    roas: cost > 0 ? Number((convValue / cost).toFixed(2)) : 0\n  };\n});\n\n// delivery guard: under 2k impressions or $15 the sample is noise\nreturn rows.filter(r => r.impressions >= 2000 && r.spend >= 15).map(r => ({ json: r }));"}},{"id":"a1b2c3d4-0005-4a11-8b01-000000000005","name":"Score Hooks & Decide","type":"n8n-nodes-base.code","typeVersion":2,"position":[320,300],"parameters":{"jsCode":"// ---- Hook scoring + scale/kill decisions -------------------------------\nconst TARGET_CPA = 45;        // blended CPA we are allowed to pay\nconst MIN_SPEND = 60;         // below this the sample is noise\nconst MAX_DAILY_BUDGET = 900; // account guardrail per campaign\nconst SCALE_STEP = 0.25;      // +25% per pass, YouTube learning is fragile\n\nconst rows = $input.all().map(i => i.json);\nif (!rows.length) return [];\n\nconst pct = (arr, p) => {\n  const s = arr.filter(v => typeof v === 'number' && isFinite(v)).sort((a, b) => a - b);\n  if (!s.length) return 0;\n  return s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))];\n};\n\n// Account benchmarks so thresholds move with the account, not with a hardcoded guess\nconst medRetention = pct(rows.map(r => r.hook_retention), 0.5);\nconst medViewRate  = pct(rows.map(r => r.view_rate), 0.5);\nconst medCpv       = pct(rows.filter(r => r.cpv !== null).map(r => r.cpv), 0.5) || 0.03;\nconst totalSpend   = rows.reduce((a, r) => a + r.spend, 0);\nconst totalConv    = rows.reduce((a, r) => a + r.conversions, 0);\nconst accountCpa   = totalConv > 0 ? totalSpend / totalConv : TARGET_CPA * 2;\n\n// ---- dedupe: the same hook is often duplicated across ad groups.\n// Roll spend/conv up per hook, keep the single best-performing ad as the carrier.\nconst byHook = new Map();\nfor (const r of rows) {\n  const g = byHook.get(r.hook_key) || { spend: 0, views: 0, conversions: 0, value: 0, impressions: 0, ads: [] };\n  g.spend += r.spend; g.views += r.views; g.conversions += r.conversions;\n  g.value += r.conversions_value; g.impressions += r.impressions; g.ads.push(r);\n  byHook.set(r.hook_key, g);\n}\n\nconst decisions = [];\nfor (const [key, g] of byHook) {\n  const carrier = g.ads.slice().sort((a, b) => b.spend - a.spend)[0];\n  const viewRate = g.impressions > 0 ? g.views / g.impressions : 0;\n  const retention = g.views > 0\n    ? g.ads.reduce((a, r) => a + r.hook_retention * r.views, 0) / g.views  // view-weighted\n    : 0;\n  const cpv = g.views > 0 ? g.spend / g.views : null;\n  const cpa = g.conversions > 0 ? g.spend / g.conversions : null;\n  const roas = g.spend > 0 ? g.value / g.spend : 0;\n\n  // ---- hook score 0-100: 45% first-5s retention, 25% view rate, 20% CPA, 10% CPV\n  const retIdx  = medRetention > 0 ? Math.min(2, retention / medRetention) : 1;\n  const viewIdx = medViewRate  > 0 ? Math.min(2, viewRate / medViewRate)   : 1;\n  const cpaIdx  = cpa ? Math.min(2, TARGET_CPA / cpa) : 0.4;   // no conversions = penalised, not zeroed\n  const cpvIdx  = cpv ? Math.min(2, medCpv / cpv) : 1;\n  const score = Number((((retIdx * 45) + (viewIdx * 25) + (cpaIdx * 20) + (cpvIdx * 10)) / 2).toFixed(1));\n\n  const matured = g.spend >= MIN_SPEND;\n  const isWinner = matured && score >= 62 && retention >= medRetention && (cpa === null ? false : cpa <= TARGET_CPA);\n  const isLoser  = matured && (score < 38 || (cpa !== null && cpa > TARGET_CPA * 1.8) || (cpa === null && g.spend >= TARGET_CPA * 1.5));\n\n  // ---- pacing projection: does a +25% bump keep us under the guardrail and under target CPA?\n  const currentBudget = carrier.daily_budget || Math.max(20, g.spend / 7);\n  const proposedBudget = Math.round(currentBudget * (1 + SCALE_STEP));\n  const projectedDailySpend = proposedBudget * 0.92;                    // YouTube under-delivers ~8%\n  const projectedDailyConv = cpa ? projectedDailySpend / (cpa * 1.12) : 0; // assume 12% CPA decay when scaling\n  const projectedCpa = projectedDailyConv > 0 ? projectedDailySpend / projectedDailyConv : null;\n  const hasHeadroom = proposedBudget <= MAX_DAILY_BUDGET && projectedCpa !== null && projectedCpa <= TARGET_CPA * 1.15;\n\n  decisions.push({\n    hook: carrier.hook,\n    hook_key: key,\n    campaign_name: carrier.campaign_name,\n    campaign_resource: carrier.campaign_resource,\n    ad_group_name: carrier.ad_group_name,\n    ad_resource: carrier.ad_resource,\n    budget_resource: carrier.budget_resource,\n    customer_id: carrier.customer_id,\n    duplicate_ads: g.ads.length,\n    spend: Number(g.spend.toFixed(2)),\n    views: g.views,\n    conversions: Number(g.conversions.toFixed(2)),\n    hook_retention: Number(retention.toFixed(4)),\n    view_rate: Number(viewRate.toFixed(4)),\n    cpv: cpv === null ? null : Number(cpv.toFixed(4)),\n    cpa: cpa === null ? null : Number(cpa.toFixed(2)),\n    roas: Number(roas.toFixed(2)),\n    hook_score: score,\n    benchmark_retention: Number(medRetention.toFixed(4)),\n    account_cpa: Number(accountCpa.toFixed(2)),\n    current_budget: Number(currentBudget.toFixed(2)),\n    proposed_budget: proposedBudget,\n    proposed_budget_micros: proposedBudget * 1000000,\n    projected_cpa: projectedCpa === null ? null : Number(projectedCpa.toFixed(2)),\n    has_headroom: hasHeadroom,\n    is_winner: isWinner,\n    is_loser: isLoser,\n    verdict: isWinner ? 'scale' : (isLoser ? 'kill' : 'watch')\n  });\n}\n\ndecisions.sort((a, b) => b.hook_score - a.hook_score);\nreturn decisions.map(d => ({ json: d }));"}},{"id":"a1b2c3d4-0006-4a11-8b01-000000000006","name":"Is Winning Hook?","type":"n8n-nodes-base.if","typeVersion":2,"position":[580,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"w1","leftValue":"={{ $json.is_winner }}","rightValue":true,"operator":{"type":"boolean","operation":"true","singleValue":true}}]},"options":{}}},{"id":"a1b2c3d4-0007-4a11-8b01-000000000007","name":"Budget Headroom Left?","type":"n8n-nodes-base.if","typeVersion":2,"position":[840,60],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"h1","leftValue":"={{ $json.has_headroom }}","rightValue":true,"operator":{"type":"boolean","operation":"true","singleValue":true}}]},"options":{}}},{"id":"a1b2c3d4-0008-4a11-8b01-000000000008","name":"Raise Campaign Budget +25%","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1100,-80],"parameters":{"method":"POST","url":"https://googleads.googleapis.com/v18/customers/{{ $json.customer_id }}/campaignBudgets:mutate","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ operations: [ { update: { resourceName: $json.budget_resource, amountMicros: $json.proposed_budget_micros }, updateMask: 'amount_micros' } ] }) }}","options":{}}},{"id":"a1b2c3d4-0009-4a11-8b01-000000000009","name":"Tag Scaled Winner","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1360,-80],"parameters":{"assignments":{"assignments":[{"id":"s1","name":"action","value":"scaled","type":"string"},{"id":"s2","name":"note","value":"=budget {{ $('Budget Headroom Left?').item.json.current_budget }} -> {{ $('Budget Headroom Left?').item.json.proposed_budget }}/day, projected CPA {{ $('Budget Headroom Left?').item.json.projected_cpa }}","type":"string"},{"id":"s3","name":"hook","value":"={{ $('Budget Headroom Left?').item.json.hook }}","type":"string"},{"id":"s4","name":"campaign_name","value":"={{ $('Budget Headroom Left?').item.json.campaign_name }}","type":"string"},{"id":"s5","name":"ad_group_name","value":"={{ $('Budget Headroom Left?').item.json.ad_group_name }}","type":"string"},{"id":"s6","name":"hook_score","value":"={{ $('Budget Headroom Left?').item.json.hook_score }}","type":"number"},{"id":"s7","name":"hook_retention","value":"={{ $('Budget Headroom Left?').item.json.hook_retention }}","type":"number"},{"id":"s8","name":"view_rate","value":"={{ $('Budget Headroom Left?').item.json.view_rate }}","type":"number"},{"id":"s9","name":"cpv","value":"={{ $('Budget Headroom Left?').item.json.cpv }}","type":"number"},{"id":"s10","name":"cpa","value":"={{ $('Budget Headroom Left?').item.json.cpa }}","type":"number"},{"id":"s11","name":"roas","value":"={{ $('Budget Headroom Left?').item.json.roas }}","type":"number"},{"id":"s12","name":"spend","value":"={{ $('Budget Headroom Left?').item.json.spend }}","type":"number"},{"id":"s13","name":"current_budget","value":"={{ $('Budget Headroom Left?').item.json.current_budget }}","type":"number"},{"id":"s14","name":"proposed_budget","value":"={{ $('Budget Headroom Left?').item.json.proposed_budget }}","type":"number"},{"id":"s15","name":"projected_cpa","value":"={{ $('Budget Headroom Left?').item.json.projected_cpa }}","type":"number"}]},"options":{}}},{"id":"a1b2c3d4-000a-4a11-8b01-00000000000a","name":"Hold Winner At Budget Cap","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1100,140],"parameters":{"assignments":{"assignments":[{"id":"c1","name":"action","value":"budget_capped","type":"string"},{"id":"c2","name":"note","value":"=winner but no headroom: proposed {{ $json.proposed_budget }}/day breaks the $900 cap or projected CPA {{ $json.projected_cpa }} exceeds target - duplicate into a fresh campaign instead","type":"string"}]},"includeOtherFields":true,"options":{}}},{"id":"a1b2c3d4-000b-4a11-8b01-00000000000b","name":"Is Dead Hook?","type":"n8n-nodes-base.if","typeVersion":2,"position":[840,540],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"l1","leftValue":"={{ $json.is_loser }}","rightValue":true,"operator":{"type":"boolean","operation":"true","singleValue":true}}]},"options":{}}},{"id":"a1b2c3d4-000c-4a11-8b01-00000000000c","name":"Pause Dead Video Ad","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1100,700],"parameters":{"method":"POST","url":"https://googleads.googleapis.com/v18/customers/{{ $json.customer_id }}/adGroupAds:mutate","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ operations: [ { update: { resourceName: $json.ad_resource, status: 'PAUSED' }, updateMask: 'status' } ] }) }}","options":{}}},{"id":"a1b2c3d4-000d-4a11-8b01-00000000000d","name":"Tag Paused Loser","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1360,700],"parameters":{"assignments":{"assignments":[{"id":"p1","name":"action","value":"paused","type":"string"},{"id":"p2","name":"note","value":"=score {{ $('Is Dead Hook?').item.json.hook_score }} with {{ $('Is Dead Hook?').item.json.spend }} spent and CPA {{ $('Is Dead Hook?').item.json.cpa }}","type":"string"},{"id":"p3","name":"hook","value":"={{ $('Is Dead Hook?').item.json.hook }}","type":"string"},{"id":"p4","name":"campaign_name","value":"={{ $('Is Dead Hook?').item.json.campaign_name }}","type":"string"},{"id":"p5","name":"ad_group_name","value":"={{ $('Is Dead Hook?').item.json.ad_group_name }}","type":"string"},{"id":"p6","name":"hook_score","value":"={{ $('Is Dead Hook?').item.json.hook_score }}","type":"number"},{"id":"p7","name":"hook_retention","value":"={{ $('Is Dead Hook?').item.json.hook_retention }}","type":"number"},{"id":"p8","name":"view_rate","value":"={{ $('Is Dead Hook?').item.json.view_rate }}","type":"number"},{"id":"p9","name":"cpv","value":"={{ $('Is Dead Hook?').item.json.cpv }}","type":"number"},{"id":"p10","name":"cpa","value":"={{ $('Is Dead Hook?').item.json.cpa }}","type":"number"},{"id":"p11","name":"roas","value":"={{ $('Is Dead Hook?').item.json.roas }}","type":"number"},{"id":"p12","name":"spend","value":"={{ $('Is Dead Hook?').item.json.spend }}","type":"number"},{"id":"p13","name":"current_budget","value":"={{ $('Is Dead Hook?').item.json.current_budget }}","type":"number"}]},"options":{}}},{"id":"a1b2c3d4-000e-4a11-8b01-00000000000e","name":"Keep On Watchlist","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1100,440],"parameters":{"assignments":{"assignments":[{"id":"x1","name":"action","value":"watchlist","type":"string"},{"id":"x2","name":"note","value":"=middle of the pack: score {{ $json.hook_score }} vs retention benchmark {{ $json.benchmark_retention }} - leave budget flat another 3 days","type":"string"}]},"includeOtherFields":true,"options":{}}},{"id":"a1b2c3d4-000f-4a11-8b01-00000000000f","name":"Consolidate Daily Actions","type":"n8n-nodes-base.code","typeVersion":2,"position":[1620,300],"parameters":{"jsCode":"// Every branch (scaled / capped / paused / watchlist) funnels back in here.\nconst items = $input.all().map(i => i.json);\nconst rank = { scaled: 0, budget_capped: 1, paused: 2, watchlist: 3 };\nconst rows = items.map(r => ({\n  run_date: new Date().toISOString().slice(0, 10),\n  action: r.action || (r.is_winner ? 'scaled' : (r.is_loser ? 'paused' : 'watchlist')),\n  hook: r.hook,\n  campaign_name: r.campaign_name,\n  ad_group_name: r.ad_group_name,\n  hook_score: r.hook_score,\n  hook_retention: r.hook_retention,\n  view_rate: r.view_rate,\n  cpv: r.cpv,\n  cpa: r.cpa,\n  roas: r.roas,\n  spend: r.spend,\n  current_budget: r.current_budget,\n  new_budget: r.action === 'scaled' ? r.proposed_budget : r.current_budget,\n  projected_cpa: r.projected_cpa,\n  note: r.note || ''\n}));\nrows.sort((a, b) => (rank[a.action] ?? 9) - (rank[b.action] ?? 9) || b.hook_score - a.hook_score);\nreturn rows.map(r => ({ json: r }));"}},{"id":"a1b2c3d4-0010-4a11-8b01-000000000010","name":"Log Actions To Sheet","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1880,300],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"YouTube hook log"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"a1b2c3d4-0011-4a11-8b01-000000000011","name":"Build Variant Brief Input","type":"n8n-nodes-base.code","typeVersion":2,"position":[2140,300],"parameters":{"jsCode":"// Build the creative brief input: what to clone, what to avoid.\nconst rows = $input.all().map(i => i.json);\nconst winners = rows.filter(r => r.action === 'scaled' || r.action === 'budget_capped')\n  .sort((a, b) => b.hook_score - a.hook_score).slice(0, 3);\nconst losers = rows.filter(r => r.action === 'paused')\n  .sort((a, b) => a.hook_score - b.hook_score).slice(0, 3);\n\nif (!winners.length) return [];\n\nconst fmt = r => `\"${r.hook}\" — hook score ${r.hook_score}, 5s retention ${(r.hook_retention * 100).toFixed(1)}%, view rate ${(r.view_rate * 100).toFixed(1)}%, CPV $${r.cpv}, CPA $${r.cpa}`;\n\nreturn [{ json: {\n  winner_count: winners.length,\n  top_hook: winners[0].hook,\n  top_score: winners[0].hook_score,\n  winners_block: winners.map(fmt).join('\\n'),\n  losers_block: losers.length ? losers.map(fmt).join('\\n') : 'none paused this run',\n  benchmark_retention: (rows[0] && rows[0].hook_retention) || 0,\n  scaled_count: rows.filter(r => r.action === 'scaled').length,\n  paused_count: losers.length,\n  total_spend: Number(rows.reduce((a, r) => a + (r.spend || 0), 0).toFixed(2))\n} }];"}},{"id":"a1b2c3d4-0012-4a11-8b01-000000000012","name":"Write 5 New Hook Scripts","type":"@n8n/n8n-nodes-langchain.openAi","typeVersion":1.8,"position":[2400,300],"parameters":{"modelId":{"__rl":true,"value":"gpt-4o-mini","mode":"list"},"messages":{"values":[{"role":"system","content":"You are a direct response YouTube ads writer. Skippable in-stream: the first 5 seconds decide everything. Return ONLY a JSON array of 5 objects with keys hook_line, first_5_seconds, angle, visual_direction, cta. No prose, no markdown."},{"content":"=These YouTube hooks are winning right now:\n{{ $json.winners_block }}\n\nThese ones died and were paused:\n{{ $json.losers_block }}\n\nWrite 5 NEW first-5-second hooks that inherit the mechanism of the winners (especially \"{{ $json.top_hook }}\", score {{ $json.top_score }}) without repeating their wording, and that avoid whatever made the dead ones lose. first_5_seconds must be literal on-screen script for 0:00-0:05."}]},"options":{}}},{"id":"a1b2c3d4-0013-4a11-8b01-000000000013","name":"Parse Variant Briefs","type":"n8n-nodes-base.code","typeVersion":2,"position":[2660,300],"parameters":{"jsCode":"// Turn the model output into one sheet row per briefed variant.\nconst raw = $input.first().json;\nconst text = raw.message && raw.message.content ? raw.message.content : (raw.content || raw.text || '');\nlet parsed = [];\ntry {\n  const start = text.indexOf('[');\n  const end = text.lastIndexOf(']');\n  if (start !== -1 && end > start) parsed = JSON.parse(text.slice(start, end + 1));\n} catch (e) { parsed = []; }\n\nif (!Array.isArray(parsed) || !parsed.length) {\n  return [{ json: {\n    run_date: new Date().toISOString().slice(0, 10),\n    status: 'brief_parse_failed',\n    raw_output: String(text).slice(0, 1500)\n  } }];\n}\n\nconst seen = new Set();\nconst out = [];\nfor (const v of parsed) {\n  const line = String(v.hook_line || v.hook || '').trim();\n  const key = line.toLowerCase().replace(/[^a-z0-9 ]/g, '').trim();\n  if (!line || seen.has(key)) continue;   // never brief the same hook twice\n  seen.add(key);\n  out.push({ json: {\n    run_date: new Date().toISOString().slice(0, 10),\n    status: 'to_produce',\n    parent_hook: $('Build Variant Brief Input').first().json.top_hook,\n    hook_line: line,\n    first_5_seconds: String(v.first_5_seconds || '').trim(),\n    angle: String(v.angle || '').trim(),\n    visual_direction: String(v.visual_direction || '').trim(),\n    cta: String(v.cta || 'Start free trial').trim()\n  } });\n}\nreturn out;"}},{"id":"a1b2c3d4-0014-4a11-8b01-000000000014","name":"Queue Briefs For Production","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2920,300],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"Creative queue"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"a1b2c3d4-0015-4a11-8b01-000000000015","name":"Post Daily Hook Report","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[3180,300],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#youtube-ads","mode":"name"},"text":"=📺 YouTube hooks — {{ $now.format('yyyy-LL-dd') }}\nScaled {{ $('Build Variant Brief Input').first().json.scaled_count }} · paused {{ $('Build Variant Brief Input').first().json.paused_count }} · 7d spend ${{ $('Build Variant Brief Input').first().json.total_spend }}\nBest hook: \"{{ $('Build Variant Brief Input').first().json.top_hook }}\" (score {{ $('Build Variant Brief Input').first().json.top_score }})\n{{ $items().length }} new variants briefed → Creative queue tab","otherOptions":{}}},{"id":"a1b2c3d4-1001-4a11-8b01-000000001001","name":"Note - Pull & Score","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-500,40],"parameters":{"content":"## 1. PULL + SCORE HOOKS\nGAQL over ad_group_ad for VIDEO campaigns, last 7 days.\nAds under 2k impressions / $15 are noise and get dropped.\n\nHooks are deduped across ad groups, then scored 0-100:\n45% first-5s retention (p25 rate), 25% view rate,\n20% CPA vs $45 target, 10% CPV vs account median.","height":340,"width":460,"color":4}},{"id":"a1b2c3d4-1002-4a11-8b01-000000001002","name":"Note - Scale Or Kill","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[560,-220],"parameters":{"content":"## 2. SCALE / CAP / PAUSE\nWinner (score 62+, retention above benchmark, CPA at target)\n→ +25% budget IF the pacing projection stays under the $900\ncap and under 1.15x target CPA. Otherwise hold and duplicate.\nLoser (score < 38 or CPA > 1.8x target) → pause the ad.\nEverything else sits on the watchlist for 3 more days.","height":300,"width":480,"color":3}},{"id":"a1b2c3d4-1003-4a11-8b01-000000001003","name":"Note - Brief Next Batch","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[2120,40],"parameters":{"content":"## 3. BRIEF THE NEXT BATCH\nTop 3 winning hooks + top 3 dead hooks go to the model,\nwhich writes 5 new 0:00-0:05 scripts that inherit the\nwinning mechanism. Duplicates are dropped, the rest land\nin the Creative queue tab and get announced in Slack.","height":280,"width":460,"color":5}}],"connections":{"Every Morning 7am":{"main":[[{"node":"Pull YouTube Ad Performance","type":"main","index":0}]]},"Pull YouTube Ad Performance":{"main":[[{"node":"Normalize Video Metrics","type":"main","index":0}]]},"Normalize Video Metrics":{"main":[[{"node":"Score Hooks & Decide","type":"main","index":0}]]},"Score Hooks & Decide":{"main":[[{"node":"Is Winning Hook?","type":"main","index":0}]]},"Is Winning Hook?":{"main":[[{"node":"Budget Headroom Left?","type":"main","index":0}],[{"node":"Is Dead Hook?","type":"main","index":0}]]},"Budget Headroom Left?":{"main":[[{"node":"Raise Campaign Budget +25%","type":"main","index":0}],[{"node":"Hold Winner At Budget Cap","type":"main","index":0}]]},"Raise Campaign Budget +25%":{"main":[[{"node":"Tag Scaled Winner","type":"main","index":0}]]},"Tag Scaled Winner":{"main":[[{"node":"Consolidate Daily Actions","type":"main","index":0}]]},"Hold Winner At Budget Cap":{"main":[[{"node":"Consolidate Daily Actions","type":"main","index":0}]]},"Is Dead Hook?":{"main":[[{"node":"Pause Dead Video Ad","type":"main","index":0}],[{"node":"Keep On Watchlist","type":"main","index":0}]]},"Pause Dead Video Ad":{"main":[[{"node":"Tag Paused Loser","type":"main","index":0}]]},"Tag Paused Loser":{"main":[[{"node":"Consolidate Daily Actions","type":"main","index":0}]]},"Keep On Watchlist":{"main":[[{"node":"Consolidate Daily Actions","type":"main","index":0}]]},"Consolidate Daily Actions":{"main":[[{"node":"Log Actions To Sheet","type":"main","index":0}]]},"Log Actions To Sheet":{"main":[[{"node":"Build Variant Brief Input","type":"main","index":0}]]},"Build Variant Brief Input":{"main":[[{"node":"Write 5 New Hook Scripts","type":"main","index":0}]]},"Write 5 New Hook Scripts":{"main":[[{"node":"Parse Variant Briefs","type":"main","index":0}]]},"Parse Variant Briefs":{"main":[[{"node":"Queue Briefs For Production","type":"main","index":0}]]},"Queue Briefs For Production":{"main":[[{"node":"Post Daily Hook Report","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}