{
  "name": "Content Repurposer with Brand Voice and Channel Rules",
  "nodes": [
    {
      "id": "de9a51be-1020-43fb-9801-372249a795fb",
      "name": "Sticky Note Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        256,
        544
      ],
      "parameters": {
        "width": 528,
        "height": 1287,
        "content": "## Content Repurposer with Brand Voice and Channel Rules\n\nOne long article becomes a set of channel ready drafts that already follow your\nbrand voice and each platform's character limits. It works two ways: as a POST\nendpoint your own tools can call, and as a daily job that picks up new posts\nfrom your blog feed on its own.\n\n### How it works\n\nAn API request is checked for sixty words and answered with a 400 when it is\ntoo thin, so no model is paid for scraps. The scheduled path reads your feed,\nkeeps only posts from the last day, strips the HTML and caps the batch at three\narticles. Both paths meet in one brief builder that trims long pieces at a\nsentence boundary. A brand voice row is then read from a sheet, giving the\nwriter the brand name, audience, tone, call to action, preferred hashtags and\nbanned words, so drafts sound like you rather than like a model. The agent\nreturns structured JSON: a summary, key points, three alternative hooks, a\nthread, a LinkedIn post, an Instagram caption, a newsletter blurb and hashtags.\nCharacter limits are then enforced in code rather than trusted to the model.\nOver-length posts are split or trimmed, hashtags deduplicated and capped,\nbanned words removed, and every fix recorded. Each draft lands in a queue tab\nas its own row marked Needs review, so a human approves before publishing. API\ncallers get the set back as JSON, scheduled runs post a Slack summary.\n\n### Setup\n\nConnect Sheets, Slack and your model credentials. Fill one Brand Voice row and\ncreate the Content Queue tab with the columns named on those nodes. Set your\nfeed URL and Slack channel.\n\n### Customization\n\nChange the channel limits or add a channel in the fan-out step."
      },
      "typeVersion": 1
    },
    {
      "id": "de9763fd-d27e-4995-9bff-f1336f2f33ff",
      "name": "Sticky Note 1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1088,
        688
      ],
      "parameters": {
        "color": 7,
        "width": 788,
        "height": 604,
        "content": "## Call it from your own tools\nA POST request with a content field. Anything under sixty words is rejected with a 400 before a model is called."
      },
      "typeVersion": 1
    },
    {
      "id": "3a2b4f6b-66c0-4208-98d0-5f06f6d97626",
      "name": "Sticky Note 2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1088,
        1328
      ],
      "parameters": {
        "color": 7,
        "width": 788,
        "height": 344,
        "content": "## Or let it watch your blog\nEach morning the feed is read, posts older than a day are dropped, the HTML is stripped and the batch is capped so one busy day cannot run up a bill."
      },
      "typeVersion": 1
    },
    {
      "id": "24ef9109-e150-48e2-8f48-cd1db9151b77",
      "name": "Sticky Note 3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1936,
        1008
      ],
      "parameters": {
        "color": 7,
        "width": 788,
        "height": 344,
        "content": "## One brief, your brand voice\nBoth entry points meet here. Long pieces are trimmed at a sentence boundary, then the brand voice row is attached to every brief."
      },
      "typeVersion": 1
    },
    {
      "id": "340b648a-0802-4124-9e43-2e98d54ccd9d",
      "name": "Sticky Note 4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2784,
        1008
      ],
      "parameters": {
        "color": 7,
        "width": 788,
        "height": 604,
        "content": "## Write the drafts\nThe agent returns structured JSON for every channel at once. Gemini stands in if the main model fails."
      },
      "typeVersion": 1
    },
    {
      "id": "fcb7ca83-09d2-4ef3-8f91-f9a934c91008",
      "name": "Sticky Note 5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3632,
        1008
      ],
      "parameters": {
        "color": 7,
        "width": 788,
        "height": 344,
        "content": "## Enforce the rules, then queue\nCharacter limits, hashtag caps and banned words are applied in code, not trusted to the model. Each draft lands in the queue tab as its own row marked Needs review."
      },
      "typeVersion": 1
    },
    {
      "id": "7407028f-d2ee-42d7-89bf-9e691f28ec2d",
      "name": "Sticky Note 6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4464,
        944
      ],
      "parameters": {
        "color": 7,
        "width": 788,
        "height": 544,
        "content": "## Answer or announce\nThe run collapses to one item. API callers get every draft back as JSON, scheduled runs post a summary to Slack."
      },
      "typeVersion": 1
    },
    {
      "id": "ff9cb306-1027-4201-9df0-a21238b267fe",
      "name": "Content Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        1168,
        848
      ],
      "parameters": {
        "path": "repurpose",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "1073ed03-7542-4d7c-ae3c-ec552522e396",
      "name": "Normalize Webhook Input",
      "type": "n8n-nodes-base.set",
      "position": [
        1392,
        848
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "c",
              "name": "content",
              "type": "string",
              "value": "={{ ($json.body && $json.body.content ? $json.body.content : '').toString() }}"
            },
            {
              "id": "t",
              "name": "sourceTitle",
              "type": "string",
              "value": "={{ $json.body && $json.body.title ? $json.body.title : 'API request' }}"
            },
            {
              "id": "u",
              "name": "sourceUrl",
              "type": "string",
              "value": "={{ $json.body && $json.body.url ? $json.body.url : '' }}"
            },
            {
              "id": "o",
              "name": "origin",
              "type": "string",
              "value": "webhook"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "e4650eb0-63c7-42ea-8e59-efb4803eb3bf",
      "name": "Long Enough To Repurpose?",
      "type": "n8n-nodes-base.if",
      "position": [
        1616,
        848
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "words",
              "operator": {
                "type": "number",
                "operation": "gte"
              },
              "leftValue": "={{ $json.content.trim().split(/\\s+/).filter(Boolean).length }}",
              "rightValue": 60
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "f02c30d0-17de-48ed-9f23-a78185186e18",
      "name": "Respond Bad Request",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        1616,
        1104
      ],
      "parameters": {
        "options": {},
        "respondWith": "json",
        "responseBody": "{\n  \"error\": \"Send a JSON body with a content field holding at least 60 words. Shorter text does not have enough material to repurpose.\"\n}"
      },
      "typeVersion": 1.4
    },
    {
      "id": "81fa9e49-3309-45b7-8c5f-c143cdbbf6cc",
      "name": "Daily Feed Check",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        1168,
        1488
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 8
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "482b5246-e3e2-4d53-9ab0-f286caca7fbc",
      "name": "Fetch Blog Feed",
      "type": "n8n-nodes-base.rssFeedRead",
      "onError": "continueRegularOutput",
      "position": [
        1392,
        1488
      ],
      "parameters": {
        "url": "YOUR_BLOG_RSS_FEED_URL",
        "options": {}
      },
      "typeVersion": 1.2,
      "alwaysOutputData": true
    },
    {
      "id": "b8a3c6c0-953e-420c-a706-c67e2b76ede3",
      "name": "Pick New Posts",
      "type": "n8n-nodes-base.code",
      "position": [
        1616,
        1488
      ],
      "parameters": {
        "jsCode": "\n// Keep only posts published since the last daily run, strip the HTML, and cap\n// the batch so one busy publishing day cannot run up a large model bill.\nconst MAX_PER_RUN = 3;\nconst WINDOW_HOURS = 24;\nconst MIN_WORDS = 60;\n\nconst cutoff = Date.now() - WINDOW_HOURS * 60 * 60 * 1000;\nconst out = [];\n\nfor (const item of $input.all()) {\n  const r = item.json;\n  const raw = r.contentSnippet || r.content || r['content:encoded'] || r.description || '';\n  const text = String(raw).replace(/<[^>]+>/g, ' ').replace(/&[a-z]+;/gi, ' ').replace(/\\s+/g, ' ').trim();\n\n  const stamp = r.isoDate || r.pubDate || r.published;\n  const ts = stamp ? new Date(stamp).getTime() : NaN;\n  if (Number.isFinite(ts) && ts < cutoff) continue;\n\n  if (text.split(' ').length < MIN_WORDS) continue;\n\n  out.push({\n    json: {\n      content: text,\n      sourceTitle: String(r.title || 'Untitled post').trim(),\n      sourceUrl: String(r.link || r.guid || '').trim(),\n      origin: 'feed',\n    },\n  });\n}\n\nreturn out.slice(0, MAX_PER_RUN);\n"
      },
      "typeVersion": 2
    },
    {
      "id": "67d42ae2-d873-4c1d-93f4-be3a39c332ed",
      "name": "Prepare Brief",
      "type": "n8n-nodes-base.code",
      "position": [
        2016,
        1168
      ],
      "parameters": {
        "jsCode": "\n// Give both entry points one identical shape, and trim very long articles at a\n// sentence boundary so a 20,000 word page cannot blow the model context.\nconst MAX_CHARS = 12000;\nconst out = [];\n\nfor (const item of $input.all()) {\n  const j = item.json;\n  let content = String(j.content || '').replace(/\\s+/g, ' ').trim();\n  const wordCount = content ? content.split(' ').length : 0;\n  let truncated = false;\n\n  if (content.length > MAX_CHARS) {\n    content = content.slice(0, MAX_CHARS);\n    const cut = content.lastIndexOf('. ');\n    if (cut > MAX_CHARS * 0.6) content = content.slice(0, cut + 1);\n    truncated = true;\n  }\n\n  const key = String(j.sourceUrl || j.sourceTitle || 'api-request').trim();\n\n  out.push({\n    json: {\n      draftGroupId: key + ' | ' + new Date().toISOString().slice(0, 10),\n      content: content,\n      wordCount: wordCount,\n      truncated: truncated,\n      sourceTitle: String(j.sourceTitle || 'Untitled').trim(),\n      sourceUrl: String(j.sourceUrl || '').trim(),\n      origin: String(j.origin || 'webhook'),\n      startedAt: Date.now(),\n    },\n  });\n}\n\nreturn out;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "c86fa258-831d-4c11-86cb-4677035b151a",
      "name": "Load Brand Voice",
      "type": "n8n-nodes-base.googleSheets",
      "notes": "Brand Voice tab: one row with the columns Brand Name, Audience, Tone, Call To Action, Hashtags, Banned Words, Max Hashtags",
      "onError": "continueRegularOutput",
      "position": [
        2240,
        1168
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Brand Voice"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "executeOnce": true,
      "typeVersion": 4.6,
      "alwaysOutputData": true
    },
    {
      "id": "73059822-05bc-415d-8345-7e73dc9c4a90",
      "name": "Apply Brand Voice",
      "type": "n8n-nodes-base.code",
      "position": [
        2448,
        1168
      ],
      "parameters": {
        "jsCode": "\n// Read the single brand voice row and attach it to every brief. When the tab is\n// empty or unreachable, sensible defaults keep the workflow running.\nconst row = $input.first() ? ($input.first().json || {}) : {};\n\nfunction pick(keys, fallback) {\n  for (const k of keys) {\n    const v = row[k];\n    if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();\n  }\n  return fallback;\n}\n\nfunction list(value) {\n  return String(value || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean);\n}\n\nconst voice = {\n  brandName: pick(['Brand Name', 'brandName'], 'the brand'),\n  audience: pick(['Audience', 'audience'], 'a general business audience'),\n  tone: pick(['Tone', 'tone'], 'clear, plain and useful'),\n  callToAction: pick(['Call To Action', 'CTA', 'cta'], 'Read the full post'),\n  hashtags: list(pick(['Hashtags', 'hashtags'], '')),\n  bannedWords: list(pick(['Banned Words', 'bannedWords'], '')),\n  maxHashtags: Number(pick(['Max Hashtags', 'maxHashtags'], '5')) || 5,\n};\n\nreturn $('Prepare Brief').all().map(function (i) {\n  return { json: Object.assign({}, i.json, { voice: voice }) };\n});\n"
      },
      "typeVersion": 2
    },
    {
      "id": "a4870fda-76f7-46ba-8d2d-f90e7011746a",
      "name": "Repurposing Agent",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        2944,
        1168
      ],
      "parameters": {
        "text": "=Repurpose the content below.\n\nBrand: {{ $json.voice.brandName }}\nAudience: {{ $json.voice.audience }}\nTone: {{ $json.voice.tone }}\nCall to action: {{ $json.voice.callToAction }}\nBanned words: {{ $json.voice.bannedWords.length ? $json.voice.bannedWords.join(', ') : 'none' }}\nSource title: {{ $json.sourceTitle }}\nSource link: {{ $json.sourceUrl || 'not provided' }}\nSource length: {{ $json.wordCount }} words{{ $json.truncated ? ' (trimmed for length, work with what is here)' : '' }}\n\nContent:\n{{ $json.content }}",
        "options": {
          "systemMessage": "You turn one long piece of content into ready to post drafts for several channels.\n\nVoice\n- Write as the brand you are given, for the audience you are given, in the tone you are given.\n- Never use the banned words listed in the brief.\n- Never invent statistics, quotes, prices, dates or customer names. Use only what the source text says.\n- Do not use emoji, exclamation marks or phrases like game changer, unlock, supercharge or dive in.\n\nPer channel\n- summary: two or three sentences a busy reader can skim.\n- keyPoints: four to six short takeaways, each a full sentence.\n- hookVariants: three different opening lines for the same idea, so a human can pick one.\n- tweetThread: four to seven posts. Each one must be under 270 characters and must make sense on its own.\n- linkedinPost: one post of roughly 120 to 200 words, first person, ending in the call to action from the brief.\n- instagramCaption: under 150 words, plain sentences, no hashtags in the text itself.\n- newsletterBlurb: a short paragraph that teases the piece and links out.\n- hashtags: three to five topical tags without the hash symbol.\n\nReturn only the requested JSON fields."
        },
        "promptType": "define",
        "needsFallback": true,
        "hasOutputParser": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "66918428-30ac-4ec5-b0ba-4835d6bb26de",
      "name": "Writing Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        2864,
        1424
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4.1-mini"
        },
        "options": {
          "temperature": 0.6
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "a945a269-2cf9-4bae-a367-031c7815b0ea",
      "name": "Fallback Writing Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
      "position": [
        3072,
        1424
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 1
    },
    {
      "id": "4ffbc9a6-bad2-4122-a222-32a63decc75b",
      "name": "Draft Format",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "position": [
        3296,
        1424
      ],
      "parameters": {
        "autoFix": true,
        "jsonSchemaExample": "{\n  \"summary\": \"Prefabricated steel kits ship as ready to assemble parts, which is why small workshops choose them over poured foundations and framing crews.\",\n  \"keyPoints\": [\n    \"Kits arrive cut to size, so site work is assembly rather than fabrication.\"\n  ],\n  \"hookVariants\": [\n    \"Most workshop owners pick steel for one reason, and it is not the price.\"\n  ],\n  \"tweetThread\": [\n    \"Steel building kits ship as ready to assemble parts. Here is what that changes on site.\"\n  ],\n  \"linkedinPost\": \"We keep being asked why steel kits go up so much faster...\",\n  \"instagramCaption\": \"Ready to assemble steel kits mean the slab is the slow part, not the walls.\",\n  \"newsletterBlurb\": \"Steel kits change the order of work on a small build site. We broke down why.\",\n  \"hashtags\": [\n    \"steelbuildings\",\n    \"prefab\",\n    \"construction\"\n  ]\n}"
      },
      "typeVersion": 1.3
    },
    {
      "id": "1164b72a-cafa-436d-b639-d6af93d0a16c",
      "name": "Enforce Channel Limits",
      "type": "n8n-nodes-base.code",
      "position": [
        3696,
        1168
      ],
      "parameters": {
        "jsCode": "\n// Channel limits are a hard requirement, not a suggestion, so they are applied\n// in code rather than trusted to the model. Anything over length is repaired\n// and the repair is recorded, so a reviewer can see what the model got wrong.\nconst LIMITS = { tweet: 280, linkedin: 3000, instagram: 2200, newsletter: 900, summary: 600 };\n\nfunction clean(text, banned) {\n  let t = String(text || '').replace(/\\r/g, '').trim();\n  for (const word of banned) {\n    if (!word) continue;\n    t = t.replace(new RegExp('\\\\b' + word.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + '\\\\b', 'gi'), '').replace(/\\s{2,}/g, ' ');\n  }\n  return t.trim();\n}\n\nfunction trim(text, limit) {\n  if (text.length <= limit) return { text: text, repaired: false };\n  let cut = text.slice(0, limit);\n  const stop = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('\\n'));\n  if (stop > limit * 0.6) cut = cut.slice(0, stop + 1);\n  return { text: cut.trim(), repaired: true };\n}\n\nfunction splitTweet(text, limit) {\n  const parts = [];\n  let rest = String(text || '').trim();\n  while (rest.length > limit) {\n    let cut = rest.slice(0, limit);\n    const stop = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf(', '), cut.lastIndexOf(' '));\n    if (stop > limit * 0.5) cut = rest.slice(0, stop + 1);\n    parts.push(cut.trim());\n    rest = rest.slice(cut.length).trim();\n  }\n  if (rest) parts.push(rest);\n  return parts;\n}\n\nconst out = [];\n\nfor (const item of $input.all()) {\n  const brief = $('Apply Brand Voice').all()[out.length];\n  const source = brief ? brief.json : {};\n  const voice = source.voice || { bannedWords: [], hashtags: [], maxHashtags: 5 };\n  const banned = voice.bannedWords || [];\n  const draft = item.json.output || {};\n  const repairs = [];\n\n  // Hashtags: dedupe, force a leading hash, and respect the brand cap.\n  const tags = [];\n  for (const t of [].concat(draft.hashtags || [], voice.hashtags || [])) {\n    const tag = String(t || '').trim().replace(/^#*/, '');\n    if (!tag) continue;\n    const full = '#' + tag;\n    if (!tags.some(function (x) { return x.toLowerCase() === full.toLowerCase(); })) tags.push(full);\n  }\n  if (tags.length > voice.maxHashtags) {\n    repairs.push('Trimmed hashtags from ' + tags.length + ' to ' + voice.maxHashtags);\n    tags.length = voice.maxHashtags;\n  }\n\n  // Thread: every tweet is forced under the character limit.\n  const thread = [];\n  for (const t of (Array.isArray(draft.tweetThread) ? draft.tweetThread : [])) {\n    const body = clean(t, banned);\n    if (!body) continue;\n    if (body.length > LIMITS.tweet) {\n      const pieces = splitTweet(body, LIMITS.tweet);\n      repairs.push('Split one tweet of ' + body.length + ' characters into ' + pieces.length + ' parts');\n      pieces.forEach(function (p) { thread.push(p); });\n    } else {\n      thread.push(body);\n    }\n  }\n\n  const li = trim(clean(draft.linkedinPost, banned), LIMITS.linkedin);\n  if (li.repaired) repairs.push('Shortened the LinkedIn post to ' + LIMITS.linkedin + ' characters');\n\n  const ig = trim(clean(draft.instagramCaption, banned), LIMITS.instagram);\n  if (ig.repaired) repairs.push('Shortened the Instagram caption to ' + LIMITS.instagram + ' characters');\n\n  const nl = trim(clean(draft.newsletterBlurb, banned), LIMITS.newsletter);\n  if (nl.repaired) repairs.push('Shortened the newsletter blurb to ' + LIMITS.newsletter + ' characters');\n\n  const summary = trim(clean(draft.summary, banned), LIMITS.summary).text;\n\n  out.push({\n    json: {\n      draftGroupId: source.draftGroupId || '',\n      sourceTitle: source.sourceTitle || '',\n      sourceUrl: source.sourceUrl || '',\n      origin: source.origin || 'webhook',\n      wordCount: source.wordCount || 0,\n      truncated: !!source.truncated,\n      startedAt: source.startedAt || Date.now(),\n      brandName: voice.brandName,\n      callToAction: voice.callToAction,\n      summary: summary,\n      keyPoints: (Array.isArray(draft.keyPoints) ? draft.keyPoints : []).map(function (k) { return clean(k, banned); }).filter(Boolean),\n      hookVariants: (Array.isArray(draft.hookVariants) ? draft.hookVariants : []).map(function (k) { return clean(k, banned); }).filter(Boolean),\n      tweetThread: thread,\n      linkedinPost: li.text,\n      instagramCaption: ig.text,\n      newsletterBlurb: nl.text,\n      hashtags: tags,\n      repairs: repairs,\n    },\n  });\n}\n\nreturn out;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "bf0c5cfb-7aeb-49dd-b117-1eba438eb74f",
      "name": "Split Into Channel Rows",
      "type": "n8n-nodes-base.code",
      "position": [
        3920,
        1168
      ],
      "parameters": {
        "jsCode": "\n// One row per channel, so the queue tab reads like an editorial calendar and a\n// human can approve or reject each draft on its own.\nconst out = [];\n\nfor (const item of $input.all()) {\n  const d = item.json;\n  const tags = (d.hashtags || []).join(' ');\n\n  const channels = [\n    { channel: 'Summary', body: d.summary },\n    { channel: 'Key points', body: (d.keyPoints || []).map(function (k) { return '- ' + k; }).join('\\n') },\n    { channel: 'Hook options', body: (d.hookVariants || []).map(function (k, i) { return (i + 1) + '. ' + k; }).join('\\n') },\n    { channel: 'X thread', body: (d.tweetThread || []).map(function (t, i) { return (i + 1) + '/' + d.tweetThread.length + ' ' + t; }).join('\\n\\n') },\n    { channel: 'LinkedIn', body: d.linkedinPost },\n    { channel: 'Instagram', body: [d.instagramCaption, tags].filter(Boolean).join('\\n\\n') },\n    { channel: 'Newsletter', body: d.newsletterBlurb },\n  ];\n\n  for (const c of channels) {\n    if (!c.body) continue;\n    out.push({\n      json: {\n        draftId: d.draftGroupId + ' | ' + c.channel,\n        draftGroupId: d.draftGroupId,\n        channel: c.channel,\n        body: c.body,\n        characters: c.body.length,\n        sourceTitle: d.sourceTitle,\n        sourceUrl: d.sourceUrl,\n        status: 'Needs review',\n        createdAt: new Date().toISOString(),\n      },\n    });\n  }\n}\n\nreturn out;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "8315ff4b-6221-4de5-9fa6-281822a5441d",
      "name": "Save Drafts to Queue",
      "type": "n8n-nodes-base.googleSheets",
      "notes": "Content Queue tab columns: Draft ID, Created At, Source Title, Source URL, Channel, Draft, Characters, Status",
      "onError": "continueRegularOutput",
      "position": [
        4144,
        1168
      ],
      "parameters": {
        "columns": {
          "value": {
            "Draft": "={{ $json.body }}",
            "Status": "={{ $json.status }}",
            "Channel": "={{ $json.channel }}",
            "Draft ID": "={{ $json.draftId }}",
            "Characters": "={{ $json.characters }}",
            "Created At": "={{ $json.createdAt }}",
            "Source URL": "={{ $json.sourceUrl }}",
            "Source Title": "={{ $json.sourceTitle }}"
          },
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "Draft ID"
          ]
        },
        "options": {},
        "operation": "appendOrUpdate",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Content Queue"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.6
    },
    {
      "id": "489c20ee-8a3c-4a28-ba5c-7b016b4602f4",
      "name": "Build Run Summary",
      "type": "n8n-nodes-base.code",
      "position": [
        4544,
        1168
      ],
      "parameters": {
        "jsCode": "\n// Collapse the run into one item, so the caller gets a single JSON response and\n// Slack gets a single message no matter how many articles were processed.\nconst drafts = $('Enforce Channel Limits').all().map(function (i) { return i.json; });\nconst rows = $('Split Into Channel Rows').all().map(function (i) { return i.json; });\n\nconst first = drafts[0] || {};\nconst repairs = [];\ndrafts.forEach(function (d) { (d.repairs || []).forEach(function (r) { repairs.push(d.sourceTitle + ': ' + r); }); });\n\nconst lines = ['*Content repurposed*'];\ndrafts.forEach(function (d) {\n  lines.push('- ' + (d.sourceTitle || 'Untitled') + ' (' + d.wordCount + ' words' +\n    (d.truncated ? ', trimmed for length' : '') + ') into ' +\n    rows.filter(function (r) { return r.draftGroupId === d.draftGroupId; }).length + ' drafts');\n});\nlines.push('');\nlines.push('Total drafts queued: ' + rows.length + '  |  Waiting in the queue tab as Needs review');\nif (repairs.length) {\n  lines.push('');\n  lines.push('*Automatic fixes applied*');\n  repairs.slice(0, 8).forEach(function (r) { lines.push('- ' + r); });\n  if (repairs.length > 8) lines.push('- and ' + (repairs.length - 8) + ' more');\n}\n\nreturn [{\n  json: {\n    origin: first.origin || 'webhook',\n    slackText: lines.join('\\n'),\n    payload: {\n      sourcesProcessed: drafts.length,\n      draftsQueued: rows.length,\n      automaticFixes: repairs,\n      drafts: drafts.map(function (d) {\n        return {\n          sourceTitle: d.sourceTitle,\n          sourceUrl: d.sourceUrl,\n          brandName: d.brandName,\n          callToAction: d.callToAction,\n          summary: d.summary,\n          keyPoints: d.keyPoints,\n          hookVariants: d.hookVariants,\n          tweetThread: d.tweetThread,\n          linkedinPost: d.linkedinPost,\n          instagramCaption: d.instagramCaption,\n          newsletterBlurb: d.newsletterBlurb,\n          hashtags: d.hashtags,\n          sourceWordCount: d.wordCount,\n          sourceTrimmed: d.truncated,\n        };\n      }),\n      latencyMs: Date.now() - (Number(first.startedAt) || Date.now()),\n    },\n  },\n}];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "046c338c-ce97-4487-a210-0a5a5969127b",
      "name": "Came From The API?",
      "type": "n8n-nodes-base.if",
      "position": [
        4768,
        1168
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "origin",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.origin }}",
              "rightValue": "webhook"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "2fdd4dfd-cccf-4fa2-ae2f-a83f492568d5",
      "name": "Respond With Drafts",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        4992,
        1104
      ],
      "parameters": {
        "options": {},
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify($json.payload) }}"
      },
      "typeVersion": 1.4
    },
    {
      "id": "05e14f2f-9e66-40b5-b112-db26639e6051",
      "name": "Notify Team in Slack",
      "type": "n8n-nodes-base.slack",
      "onError": "continueRegularOutput",
      "position": [
        4992,
        1296
      ],
      "parameters": {
        "text": "={{ $json.slackText }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "YOUR_SLACK_CHANNEL_NAME"
        },
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "typeVersion": 2.3
    }
  ],
  "connections": {
    "Draft Format": {
      "ai_outputParser": [
        [
          {
            "node": "Repurposing Agent",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Brief": {
      "main": [
        [
          {
            "node": "Load Brand Voice",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Writing Model": {
      "ai_languageModel": [
        [
          {
            "node": "Repurposing Agent",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Pick New Posts": {
      "main": [
        [
          {
            "node": "Prepare Brief",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Content Webhook": {
      "main": [
        [
          {
            "node": "Normalize Webhook Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Blog Feed": {
      "main": [
        [
          {
            "node": "Pick New Posts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Daily Feed Check": {
      "main": [
        [
          {
            "node": "Fetch Blog Feed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Brand Voice": {
      "main": [
        [
          {
            "node": "Apply Brand Voice",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apply Brand Voice": {
      "main": [
        [
          {
            "node": "Repurposing Agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Run Summary": {
      "main": [
        [
          {
            "node": "Came From The API?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Repurposing Agent": {
      "main": [
        [
          {
            "node": "Enforce Channel Limits",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Came From The API?": {
      "main": [
        [
          {
            "node": "Respond With Drafts",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Notify Team in Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Drafts to Queue": {
      "main": [
        [
          {
            "node": "Build Run Summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Enforce Channel Limits": {
      "main": [
        [
          {
            "node": "Split Into Channel Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fallback Writing Model": {
      "ai_languageModel": [
        [
          {
            "node": "Repurposing Agent",
            "type": "ai_languageModel",
            "index": 1
          }
        ]
      ]
    },
    "Normalize Webhook Input": {
      "main": [
        [
          {
            "node": "Long Enough To Repurpose?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Into Channel Rows": {
      "main": [
        [
          {
            "node": "Save Drafts to Queue",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Long Enough To Repurpose?": {
      "main": [
        [
          {
            "node": "Prepare Brief",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond Bad Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "active": false
}
