{"name":"300 ads per week workflow","nodes":[{"id":"8f1a2c34-0001-4a11-8b01-aaaa00000001","name":"Every Weekday 07:00","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-200,300],"parameters":{"rule":{"interval":[{"field":"days","daysInterval":1}]}}},{"id":"8f1a2c34-0002-4a11-8b01-aaaa00000002","name":"Fetch Product Feed","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[60,300],"parameters":{"url":"https://feeds.brand.com/v1/catalog/ad-feed","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"id,title,product_type,price,image_url,inventory_quantity,spend,impressions,purchase_roas,frequency,ctr,creatives_live,last_brief_at"},{"name":"status","value":"active"},{"name":"window","value":"last_7d"},{"name":"limit","value":"250"}]},"options":{}}},{"id":"8f1a2c34-0003-4a11-8b01-aaaa00000003","name":"Rank SKUs By Creative Need","type":"n8n-nodes-base.code","typeVersion":2,"position":[320,300],"parameters":{"jsCode":"// ── Choose which SKUs deserve a fresh creative batch this week ─────────────\n// Feed row shape (Shopify product + last-7d Meta rollup joined upstream):\n//   id, title, product_type, price, image_url, inventory_quantity,\n//   spend, impressions, purchase_roas, frequency, ctr, creatives_live,\n//   last_brief_at (ISO)\nconst WEEKLY_QUOTA      = 300;   // briefs we want queued per week\nconst COMBOS_PER_SKU    = 12;    // 4 hooks x 3 angles, then x formats downstream\nconst MIN_INVENTORY     = 25;    // don't burn creative on near-OOS SKUs\nconst FATIGUE_FREQUENCY = 2.6;   // Meta frequency above this = audience burnt\nconst COOLDOWN_DAYS     = 5;     // don't re-brief the same SKU too fast\n\nconst now = Date.now();\nconst rows = $input.all().map(i => i.json);\n\nconst scored = rows\n  .filter(r => (r.inventory_quantity ?? 0) >= MIN_INVENTORY)\n  .filter(r => {\n    if (!r.last_brief_at) return true;\n    const days = (now - new Date(r.last_brief_at).getTime()) / 86400000;\n    return days >= COOLDOWN_DAYS;\n  })\n  .map(r => {\n    const spend       = Number(r.spend || 0);\n    const impressions = Number(r.impressions || 0);\n    const roas        = Number(r.purchase_roas || 0);\n    const freq        = Number(r.frequency || 0);\n    const ctr         = Number(r.ctr || 0);\n    const live        = Number(r.creatives_live || 0);\n\n    // 1. Proven winners deserve more variants (capped so one SKU can't eat the week)\n    const winnerPull  = Math.min(roas / 2, 2) * 30;\n    // 2. Fatigue: frequency past threshold means the current set is dying\n    const fatiguePull = Math.max(0, freq - FATIGUE_FREQUENCY) * 25;\n    // 3. Thin libraries starve the algorithm — reward SKUs with few live assets\n    const scarcity    = Math.max(0, 8 - live) * 6;\n    // 4. Weak CTR at real volume = message-market mismatch, worth re-angling\n    const ctrGap      = impressions > 20000 && ctr < 0.9 ? (0.9 - ctr) * 40 : 0;\n    // 5. Budget at risk — weight by how much money rides on this SKU\n    const spendWeight = Math.log10(spend + 10) * 8;\n\n    const score = Math.round((winnerPull + fatiguePull + scarcity + ctrGap + spendWeight) * 10) / 10;\n\n    let reason = 'library_expansion';\n    if (fatiguePull > 15)      reason = 'creative_fatigue';\n    else if (winnerPull > 45)  reason = 'scale_winner';\n    else if (ctrGap > 8)       reason = 'weak_hook';\n\n    return { ...r, brief_score: score, brief_reason: reason };\n  })\n  .sort((a, b) => b.brief_score - a.brief_score);\n\n// Only take as many SKUs as the weekly quota can actually absorb\nconst capacity = Math.ceil(WEEKLY_QUOTA / COMBOS_PER_SKU);\nreturn scored.slice(0, capacity).map((r, idx) => ({\n  json: { ...r, queue_rank: idx + 1, target_combos: COMBOS_PER_SKU, weekly_quota: WEEKLY_QUOTA }\n}));"}},{"id":"8f1a2c34-0004-4a11-8b01-aaaa00000004","name":"Loop Over SKUs","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[580,300],"parameters":{"batchSize":1,"options":{}}},{"id":"8f1a2c34-0005-4a11-8b01-aaaa00000005","name":"Generate Hooks + Angles","type":"@n8n/n8n-nodes-langchain.openAi","typeVersion":1.8,"position":[840,300],"parameters":{"modelId":{"__rl":true,"value":"gpt-4o-mini","mode":"list"},"messages":{"values":[{"role":"system","content":"You are a direct response copywriter for DTC ecommerce. Reply with STRICT JSON only, no prose, no code fences. Shape: {\"hooks\":[{\"text\":\"...\"}],\"angles\":[{\"name\":\"...\",\"primary_text\":\"...\"}]}. Give exactly 4 hooks (max 60 chars, specific, no hype words like \"unleash\" or \"game-changer\") and exactly 3 distinct angles (problem/solution, social proof, price-anchor style)."},{"content":"=Product: {{ $json.title }}\nCategory: {{ $json.product_type }}\nPrice: ${{ $json.price }}\nWhy we are re-briefing it: {{ $json.brief_reason }} (7d ROAS {{ $json.purchase_roas }}, frequency {{ $json.frequency }}, {{ $json.creatives_live }} creatives live)"}]},"options":{}}},{"id":"8f1a2c34-0006-4a11-8b01-aaaa00000006","name":"Fan Out Hook x Angle x Format","type":"n8n-nodes-base.code","typeVersion":2,"position":[1100,300],"parameters":{"jsCode":"// ── Parse the LLM copy, fan out hook x angle x format, dedupe ─────────────\nconst product = $('Loop Over SKUs').first().json; // batchSize 1 = the SKU in this iteration\nconst raw = $input.first().json;\nconst text = raw.message?.content ?? raw.content ?? raw.text ?? '';\n\nlet parsed;\ntry {\n  parsed = JSON.parse(String(text).replace(/^```(?:json)?/, '').replace(/```$/, '').trim());\n} catch (e) {\n  parsed = { hooks: [], angles: [] };\n}\n\nconst FORMATS = [\n  { format: 'static_1x1',  aspect: '1:1',  asset_type: 'image' },\n  { format: 'story_9x16',  aspect: '9:16', asset_type: 'video' },\n  { format: 'reel_ugc',    aspect: '9:16', asset_type: 'video' },\n];\n\nconst hooks  = (parsed.hooks  || []).filter(Boolean).slice(0, 4);\nconst angles = (parsed.angles || []).filter(Boolean).slice(0, 3);\n\n// cheap stable hash so the same copy never gets queued twice\nconst hash = (s) => {\n  let h = 5381;\n  for (let i = 0; i < s.length; i++) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;\n  return h.toString(36);\n};\n\nconst seen = new Set();\nconst briefs = [];\n\nfor (const hook of hooks) {\n  for (const angle of angles) {\n    for (const fmt of FORMATS) {\n      const hookText  = typeof hook  === 'string' ? hook  : (hook.text  || '');\n      const angleName = typeof angle === 'string' ? angle : (angle.name || '');\n      const angleBody = typeof angle === 'string' ? ''    : (angle.primary_text || '');\n      const key = hash([product.id, hookText, angleName, fmt.format].join('|').toLowerCase());\n      if (seen.has(key)) continue;\n      seen.add(key);\n\n      briefs.push({\n        brief_key:      key,\n        sku_id:         product.id,\n        product_title:  product.title,\n        product_type:   product.product_type,\n        price:          product.price,\n        hero_image_url: product.image_url,\n        hook:           hookText,\n        angle:          angleName,\n        primary_text:   angleBody,\n        format:         fmt.format,\n        aspect_ratio:   fmt.aspect,\n        asset_type:     fmt.asset_type,\n        brief_reason:   product.brief_reason,\n        brief_score:    product.brief_score,\n        source_roas:    product.purchase_roas,\n        source_spend:   product.spend,\n        created_at:     new Date().toISOString(),\n      });\n    }\n  }\n}\n\n// QA gate: a brief is only usable if the hook is a real, punchy line\nconst MAX_HOOK_CHARS = 60;\nconst BANNED = ['unleash', 'game-changer', 'revolutionary', 'elevate your'];\nconst checked = briefs.map(b => {\n  const h = (b.hook || '').toLowerCase();\n  const problems = [];\n  if (!b.hook || b.hook.length < 8)      problems.push('hook_too_short');\n  if (b.hook && b.hook.length > MAX_HOOK_CHARS) problems.push('hook_too_long');\n  if (BANNED.some(w => h.includes(w)))   problems.push('banned_phrase');\n  if (!b.primary_text)                   problems.push('missing_body');\n  return { ...b, qa_problems: problems.join(','), qa_pass: problems.length === 0 };\n});\n\nreturn checked.map(b => ({ json: b }));"}},{"id":"8f1a2c34-0007-4a11-8b01-aaaa00000007","name":"Brief Passes Copy QA?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1360,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"qa1","leftValue":"={{ $json.qa_pass }}","rightValue":"","operator":{"type":"boolean","operation":"true"}}]},"options":{}}},{"id":"8f1a2c34-0008-4a11-8b01-aaaa00000008","name":"Score + Rank Briefs","type":"n8n-nodes-base.code","typeVersion":2,"position":[1620,300],"parameters":{"jsCode":"// ── Priority-score each brief so the studio works the queue top-down ──────\nconst rows = $input.all().map(i => i.json);\n\nconst FORMAT_WEIGHT = { reel_ugc: 1.25, story_9x16: 1.1, static_1x1: 1.0 };\nconst REASON_WEIGHT = { scale_winner: 1.3, creative_fatigue: 1.2, weak_hook: 1.05, library_expansion: 1.0 };\n\nconst scored = rows.map(r => {\n  const base   = Number(r.brief_score || 0);\n  const fmt    = FORMAT_WEIGHT[r.format] ?? 1;\n  const reason = REASON_WEIGHT[r.brief_reason] ?? 1;\n  // short, specific hooks outperform — reward density\n  const hookLen  = (r.hook || '').length;\n  const hookBonus = hookLen >= 18 && hookLen <= 42 ? 8 : 0;\n  const priority = Math.round(base * fmt * reason + hookBonus);\n\n  return {\n    ...r,\n    priority_score: priority,\n    priority_tier: priority >= 90 ? 'P1' : priority >= 55 ? 'P2' : 'P3',\n    est_production_minutes: r.asset_type === 'video' ? 25 : 8,\n  };\n});\n\nscored.sort((a, b) => b.priority_score - a.priority_score);\n\nconst totalMinutes = scored.reduce((s, r) => s + r.est_production_minutes, 0);\nreturn scored.map((r, i) => ({\n  json: { ...r, queue_position: i + 1, batch_production_minutes: totalMinutes }\n}));"}},{"id":"8f1a2c34-0009-4a11-8b01-aaaa00000009","name":"Shape Creative Brief Row","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1880,300],"parameters":{"assignments":{"assignments":[{"id":"f1","name":"brief_key","value":"={{ $json.brief_key }}","type":"string"},{"id":"f2","name":"sku_id","value":"={{ $json.sku_id }}","type":"string"},{"id":"f3","name":"product_title","value":"={{ $json.product_title }}","type":"string"},{"id":"f4","name":"hook","value":"={{ $json.hook }}","type":"string"},{"id":"f5","name":"angle","value":"={{ $json.angle }}","type":"string"},{"id":"f6","name":"primary_text","value":"={{ $json.primary_text }}","type":"string"},{"id":"f7","name":"format","value":"={{ $json.format }}","type":"string"},{"id":"f8","name":"aspect_ratio","value":"={{ $json.aspect_ratio }}","type":"string"},{"id":"f9","name":"hero_image_url","value":"={{ $json.hero_image_url }}","type":"string"},{"id":"f10","name":"priority_tier","value":"={{ $json.priority_tier }}","type":"string"},{"id":"f11","name":"priority_score","value":"={{ $json.priority_score }}","type":"number"},{"id":"f12","name":"est_production_minutes","value":"={{ $json.est_production_minutes }}","type":"number"},{"id":"f13","name":"brief_reason","value":"={{ $json.brief_reason }}","type":"string"},{"id":"f14","name":"status","value":"Queued","type":"string"},{"id":"f15","name":"created_at","value":"={{ $json.created_at }}","type":"string"}]},"options":{}}},{"id":"8f1a2c34-0010-4a11-8b01-aaaa00000010","name":"Queue Brief In Airtable","type":"n8n-nodes-base.airtable","typeVersion":2.1,"position":[2140,300],"parameters":{"operation":"create","base":{"__rl":true,"value":"appCREATIVEQUEUE","mode":"id"},"table":{"__rl":true,"value":"tblCreativeBriefs","mode":"id"},"columns":{"mappingMode":"autoMapInputData","value":{}},"options":{}}},{"id":"8f1a2c34-0011-4a11-8b01-aaaa00000011","name":"Log Brief To Weekly Sheet","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2400,300],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"BriefLog"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"8f1a2c34-0012-4a11-8b01-aaaa00000012","name":"Log Rejected Copy","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1620,540],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=2","mode":"list","cachedResultName":"RejectedCopy"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"8f1a2c34-0013-4a11-8b01-aaaa00000013","name":"Read Weekly Brief Log","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[840,40],"parameters":{"operation":"read","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"BriefLog"},"options":{}}},{"id":"8f1a2c34-0014-4a11-8b01-aaaa00000014","name":"Compute Weekly Pacing","type":"n8n-nodes-base.code","typeVersion":2,"position":[1100,40],"parameters":{"jsCode":"// ── Weekly pacing: are we going to hit 300 briefs by Sunday? ──────────────\nconst WEEKLY_QUOTA = 300;\nconst rows = $input.all().map(i => i.json);\n\nconst now = new Date();\nconst dow = now.getUTCDay() === 0 ? 7 : now.getUTCDay(); // Mon=1 .. Sun=7\nconst weekStart = new Date(now);\nweekStart.setUTCDate(now.getUTCDate() - (dow - 1));\nweekStart.setUTCHours(0, 0, 0, 0);\n\nconst thisWeek = rows.filter(r => {\n  const t = new Date(r.created_at || r.Created || 0).getTime();\n  return t >= weekStart.getTime();\n});\n\n// dedupe defensively — the log can contain retries of the same brief_key\nconst uniq = new Map();\nfor (const r of thisWeek) uniq.set(r.brief_key || JSON.stringify(r), r);\nconst queued = uniq.size;\n\nconst byTier = { P1: 0, P2: 0, P3: 0 };\nconst byFormat = {};\nfor (const r of uniq.values()) {\n  if (byTier[r.priority_tier] !== undefined) byTier[r.priority_tier]++;\n  byFormat[r.format] = (byFormat[r.format] || 0) + 1;\n}\n\nconst perDay      = queued / dow;\nconst projected   = Math.round(perDay * 7);\nconst remaining   = Math.max(0, WEEKLY_QUOTA - queued);\nconst daysLeft    = Math.max(1, 7 - dow);\nconst neededPerDay = Math.ceil(remaining / daysLeft);\nconst pctOfQuota  = Math.round((queued / WEEKLY_QUOTA) * 100);\n\nlet status = 'on_pace';\nif (queued >= WEEKLY_QUOTA)      status = 'quota_hit';\nelse if (projected < WEEKLY_QUOTA * 0.85) status = 'behind';\n\nreturn [{ json: {\n  week_start: weekStart.toISOString().slice(0, 10),\n  day_of_week: dow,\n  briefs_queued: queued,\n  weekly_quota: WEEKLY_QUOTA,\n  pct_of_quota: pctOfQuota,\n  per_day_actual: Math.round(perDay * 10) / 10,\n  projected_week_total: projected,\n  remaining,\n  needed_per_day: neededPerDay,\n  status,\n  quota_hit: queued >= WEEKLY_QUOTA,\n  p1_count: byTier.P1, p2_count: byTier.P2, p3_count: byTier.P3,\n  format_mix: byFormat,\n  summary: queued + '/' + WEEKLY_QUOTA + ' briefs (' + pctOfQuota + '%), projecting ' + projected,\n} }];"}},{"id":"8f1a2c34-0015-4a11-8b01-aaaa00000015","name":"Weekly Quota Hit?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1360,40],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"q1","leftValue":"={{ $json.briefs_queued }}","rightValue":300,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"8f1a2c34-0016-4a11-8b01-aaaa00000016","name":"Announce Quota Hit","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1620,-160],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#creative-studio","mode":"name"},"text":"=✅ 300/week creative quota hit — {{ $json.briefs_queued }} briefs queued for week of {{ $json.week_start }} ({{ $json.pct_of_quota }}%). P1 {{ $json.p1_count }} / P2 {{ $json.p2_count }} / P3 {{ $json.p3_count }}. Studio can start pulling from the top of the Airtable queue.","otherOptions":{}}},{"id":"8f1a2c34-0017-4a11-8b01-aaaa00000017","name":"Email Pacing Nudge","type":"n8n-nodes-base.gmail","typeVersion":2.1,"position":[1620,240],"parameters":{"sendTo":"creative@brand.com","subject":"=Creative pacing: {{ $json.briefs_queued }}/300 briefs — {{ $json.status }}","message":"=Week of {{ $json.week_start }} (day {{ $json.day_of_week }}).\n\nQueued: {{ $json.briefs_queued }} of {{ $json.weekly_quota }} ({{ $json.pct_of_quota }}%)\nRunning at {{ $json.per_day_actual }} briefs/day, projecting {{ $json.projected_week_total }} by Sunday.\nTo land on quota we need {{ $json.needed_per_day }} briefs/day for the remaining days.\n\nTier mix: P1 {{ $json.p1_count }}, P2 {{ $json.p2_count }}, P3 {{ $json.p3_count }}.","options":{}}},{"id":"8f1a2c34-0018-4a11-8b01-aaaa00000018","name":"Append Pacing Snapshot","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1880,40],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"PacingSnapshots"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"8f1a2c34-1001-4a11-8b01-bbbb00000001","name":"Note Intake","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-260,60],"parameters":{"content":"## 1. FEED INTAKE + SKU RANKING\nPull the active product feed joined with last-7d Meta rollups, then score which SKUs actually need new creative: proven ROAS, frequency fatigue, thin libraries, weak CTR at volume. Only take as many SKUs as the 300/week quota can absorb (12 combos per SKU).","height":300,"width":460,"color":4}},{"id":"8f1a2c34-1002-4a11-8b01-bbbb00000002","name":"Note Fanout","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[820,460],"parameters":{"content":"## 2. COPY GEN + COMBINATORIAL FAN-OUT\nOne LLM call per SKU returns 4 hooks x 3 angles. The Code node crosses those with 3 formats (static 1x1, story 9x16, UGC reel) = 36 candidates, dedupes on a stable hash, and runs a copy QA gate (length, banned hype phrases, missing body). Failures are logged, not silently dropped.","height":320,"width":470,"color":5}},{"id":"8f1a2c34-1003-4a11-8b01-bbbb00000003","name":"Note Pacing","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[820,-320],"parameters":{"content":"## 3. QUOTA + PACING\nAfter the loop drains, re-read the brief log, dedupe by brief_key, and project the week: briefs/day so far x 7. Quota hit -> Slack the studio. Behind pace -> email the required briefs/day to catch up. Both paths log a pacing snapshot.","height":260,"width":460,"color":3}}],"connections":{"Every Weekday 07:00":{"main":[[{"node":"Fetch Product Feed","type":"main","index":0}]]},"Fetch Product Feed":{"main":[[{"node":"Rank SKUs By Creative Need","type":"main","index":0}]]},"Rank SKUs By Creative Need":{"main":[[{"node":"Loop Over SKUs","type":"main","index":0}]]},"Loop Over SKUs":{"main":[[{"node":"Read Weekly Brief Log","type":"main","index":0}],[{"node":"Generate Hooks + Angles","type":"main","index":0}]]},"Generate Hooks + Angles":{"main":[[{"node":"Fan Out Hook x Angle x Format","type":"main","index":0}]]},"Fan Out Hook x Angle x Format":{"main":[[{"node":"Brief Passes Copy QA?","type":"main","index":0}]]},"Brief Passes Copy QA?":{"main":[[{"node":"Score + Rank Briefs","type":"main","index":0}],[{"node":"Log Rejected Copy","type":"main","index":0}]]},"Score + Rank Briefs":{"main":[[{"node":"Shape Creative Brief Row","type":"main","index":0}]]},"Shape Creative Brief Row":{"main":[[{"node":"Queue Brief In Airtable","type":"main","index":0}]]},"Queue Brief In Airtable":{"main":[[{"node":"Log Brief To Weekly Sheet","type":"main","index":0}]]},"Log Brief To Weekly Sheet":{"main":[[{"node":"Loop Over SKUs","type":"main","index":0}]]},"Log Rejected Copy":{"main":[[{"node":"Loop Over SKUs","type":"main","index":0}]]},"Read Weekly Brief Log":{"main":[[{"node":"Compute Weekly Pacing","type":"main","index":0}]]},"Compute Weekly Pacing":{"main":[[{"node":"Weekly Quota Hit?","type":"main","index":0}]]},"Weekly Quota Hit?":{"main":[[{"node":"Announce Quota Hit","type":"main","index":0}],[{"node":"Email Pacing Nudge","type":"main","index":0}]]},"Announce Quota Hit":{"main":[[{"node":"Append Pacing Snapshot","type":"main","index":0}]]},"Email Pacing Nudge":{"main":[[{"node":"Append Pacing Snapshot","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}