{
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "name": "Score and rank ad copy variants with Claude AI judge and log to Google Sheets",
  "tags": [],
  "nodes": [
    {
      "id": "1d35ec2f-411d-445f-bc28-d23334d1055a",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -368,
        -32
      ],
      "parameters": {
        "width": 480,
        "height": 768,
        "content": "## Score and rank ad copy variants with Claude AI judge and log to Google Sheets\n\n### How it works\n\nThis workflow receives ad copy variants through a webhook, enriches them with evaluation criteria, and normalizes the input. It uses Claude in two passes: first to generate critic notes and then to score the variants. The workflow parses and ranks the scored variants, logs them to Google Sheets, and returns the ranked results to the webhook caller.\n\n### Setup steps\n\n- Configure the webhook URL and ensure callers send a valid variants array in the expected payload shape.\n- Update the evaluation configuration node with the campaign brief, ICP, brand voice, and target platform.\n- Configure the Anthropic Claude HTTP request nodes with a valid API key, model, headers, and prompt/body structure.\n- Connect Google Sheets credentials and select the target spreadsheet, sheet, and column mappings for the scored variant log.\n\n### Customization\n\nAdjust the evaluation criteria, Claude prompts, scoring rubric, and Google Sheets columns to match different ad platforms, brands, or campaign goals."
      },
      "typeVersion": 1
    },
    {
      "id": "e5cd39b7-d689-4124-ab4a-7ac8fe984b6f",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        192,
        160
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 320,
        "content": "## Receive and prepare variants\n\nAccepts ad copy variants via webhook, adds evaluation context such as brief, ICP, brand voice, and platform, then validates and normalizes the variant array for judging."
      },
      "typeVersion": 1
    },
    {
      "id": "80ba654c-2831-4437-9575-6f1dc45a75b2",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        880,
        144
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 336,
        "content": "## Judge and rank copy\n\nSends the normalized variants to Claude for critic notes, sends a second Claude request to score them, then parses the AI response, merges notes and scores back into the original variants, and sorts them by overall score."
      },
      "typeVersion": 1
    },
    {
      "id": "6a3b030b-412e-4989-b039-7ff27581e08d",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1568,
        -32
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 624,
        "content": "## Log and return results\n\nTakes the ranked scored variants and sends them to two outputs: appending the results to Google Sheets and returning the ranked list through the webhook response."
      },
      "typeVersion": 1
    },
    {
      "id": "a1b2c3d4-0006-4000-8000-000000000006",
      "name": "When Variants Received",
      "type": "n8n-nodes-base.webhook",
      "position": [
        240,
        320
      ],
      "parameters": {
        "path": "ad-copy-evaluator",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "a1b2c3d4-0006-4000-8000-000000000007",
      "name": "Prepare Evaluation Settings",
      "type": "n8n-nodes-base.set",
      "position": [
        460,
        320
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "e1-brief",
              "name": "brief",
              "type": "string",
              "value": "={{ $json.body?.brief || 'No brief provided. Score on general direct-response copy quality.' }}"
            },
            {
              "id": "e2-icp",
              "name": "icp",
              "type": "string",
              "value": "={{ $json.body?.icp || 'Default ICP: small-to-mid-size business decision makers, value clarity over cleverness.' }}"
            },
            {
              "id": "e3-voice",
              "name": "brandVoice",
              "type": "string",
              "value": "={{ $json.body?.brand_voice || 'Friendly, direct, confident; avoid hype words and superlatives; never use em dashes.' }}"
            },
            {
              "id": "e4-platform",
              "name": "platform",
              "type": "string",
              "value": "={{ $json.body?.platform || 'general' }}"
            },
            {
              "id": "e5-variants",
              "name": "variants",
              "type": "array",
              "value": "={{ $json.body?.variants || [] }}"
            },
            {
              "id": "e6-ts",
              "name": "submittedAt",
              "type": "string",
              "value": "={{ $now.toISO() }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "a1b2c3d4-0006-4000-8000-000000000008",
      "name": "Normalize Variants Data",
      "type": "n8n-nodes-base.code",
      "position": [
        680,
        320
      ],
      "parameters": {
        "jsCode": "// Validate the variants array and normalize each entry into a consistent shape\nconst config = $json;\nconst variants = Array.isArray(config.variants) ? config.variants : [];\n\nif (variants.length === 0) {\n  throw new Error('No variants provided. POST body must include a variants array with at least one item.');\n}\n\nconst normalized = variants.slice(0, 12).map((v, i) => ({\n  id: (v.id || ('v' + (i + 1))).toString(),\n  headline: (v.headline || '').toString().trim(),\n  body: (v.body || v.description || '').toString().trim(),\n  cta: (v.cta || '').toString().trim()\n}));\n\nreturn [{\n  json: {\n    brief: config.brief,\n    icp: config.icp,\n    brandVoice: config.brandVoice,\n    platform: config.platform,\n    submittedAt: config.submittedAt,\n    variants: normalized,\n    variantCount: normalized.length\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "a1b2c3d4-0006-4000-8000-000000000009",
      "name": "Post to Claude for Critic Notes",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        928,
        320
      ],
      "parameters": {
        "url": "https://api.anthropic.com/v1/messages",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"model\": \"claude-sonnet-4-6\",\n  \"max_tokens\": 1800,\n  \"messages\": [\n    {\n      \"role\": \"user\",\n      \"content\": \"You are a senior direct-response copywriter writing critic notes on ad copy variants. Do NOT score yet. Write a candid critique per variant covering four dimensions, plus one note on the strongest single line and the weakest single line.\\n\\nReply with valid JSON only, no markdown fences, no commentary. Use this exact schema:\\n\\n{\\n  \\\"critiques\\\": [\\n    {\\n      \\\"id\\\": \\\"<variant id>\\\",\\n      \\\"hook_critique\\\": \\\"<two sentences on how well the headline hooks the ICP>\\\",\\n      \\\"fit_critique\\\": \\\"<two sentences on ICP fit and benefit clarity>\\\",\\n      \\\"voice_critique\\\": \\\"<two sentences on brand voice match>\\\",\\n      \\\"cta_critique\\\": \\\"<two sentences on CTA clarity and friction>\\\",\\n      \\\"strongest_line\\\": \\\"<exact quote of strongest line>\\\",\\n      \\\"weakest_line\\\": \\\"<exact quote of weakest line>\\\"\\n    }\\n  ]\\n}\\n\\nBRIEF:\\n\" + {{ JSON.stringify($json.brief) }} + \"\\n\\nICP:\\n\" + {{ JSON.stringify($json.icp) }} + \"\\n\\nBRAND VOICE:\\n\" + {{ JSON.stringify($json.brandVoice) }} + \"\\n\\nVARIANTS:\\n\" + {{ JSON.stringify($json.variants) }}\n    }\n  ]\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            },
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "a1b2c3d4-0006-4000-8000-000000000010",
      "name": "Post to Claude for Scoring",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1152,
        320
      ],
      "parameters": {
        "url": "https://api.anthropic.com/v1/messages",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"model\": \"claude-sonnet-4-6\",\n  \"max_tokens\": 1500,\n  \"messages\": [\n    {\n      \"role\": \"user\",\n      \"content\": \"You are a senior direct-response strategist scoring ad copy variants. Use the critic notes below as your reasoning scratchpad, then assign integer scores from 0 to 100 on each dimension. Be strict; reserve 90+ only for variants that are genuinely outstanding.\\n\\nReply with valid JSON only, no markdown fences, no commentary. Use this exact schema:\\n\\n{\\n  \\\"scores\\\": [\\n    {\\n      \\\"id\\\": \\\"<variant id>\\\",\\n      \\\"hook\\\": <0-100>,\\n      \\\"icp_fit\\\": <0-100>,\\n      \\\"brand_voice\\\": <0-100>,\\n      \\\"cta_clarity\\\": <0-100>,\\n      \\\"overall\\\": <0-100>,\\n      \\\"verdict\\\": \\\"<one sentence verdict, max 25 words>\\\",\\n      \\\"top_fix\\\": \\\"<one sentence with the single highest-leverage edit>\\\"\\n    }\\n  ]\\n}\\n\\nScoring rubric:\\n- 90-100: outstanding, ship as is\\n- 75-89: strong, ship with minor tweak\\n- 60-74: usable but needs a clear improvement\\n- 40-59: weak, rewrite recommended\\n- 0-39: do not ship\\n\\nBRIEF: \" + {{ JSON.stringify($('Normalize Variants Data').item.json.brief) }} + \"\\n\\nICP: \" + {{ JSON.stringify($('Normalize Variants Data').item.json.icp) }} + \"\\n\\nBRAND VOICE: \" + {{ JSON.stringify($('Normalize Variants Data').item.json.brandVoice) }} + \"\\n\\nVARIANTS:\\n\" + {{ JSON.stringify($('Normalize Variants Data').item.json.variants) }} + \"\\n\\nCRITIC NOTES (use as reasoning):\\n\" + {{ JSON.stringify($json) }}\n    }\n  ]\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            },
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "a1b2c3d4-0006-4000-8000-000000000011",
      "name": "Parse and Sort Scores",
      "type": "n8n-nodes-base.code",
      "position": [
        1376,
        320
      ],
      "parameters": {
        "jsCode": "// Parse Claude's scores, merge with original variants and critic notes, sort by overall score\nconst variants = $('Normalize Variants Data').item.json.variants;\nconst submittedAt = $('Normalize Variants Data').item.json.submittedAt;\nconst platform = $('Normalize Variants Data').item.json.platform;\nconst critiqueRaw = $('Post to Claude for Critic Notes').item.json.content?.[0]?.text || '{}';\nconst scoreRaw = $json.content?.[0]?.text || '{}';\n\nlet critiques = [];\nlet scores = [];\n\ntry {\n  const cleaned = critiqueRaw.replace(/^```(?:json)?\\s*/i, '').replace(/\\s*```\\s*$/i, '').trim();\n  critiques = JSON.parse(cleaned).critiques || [];\n} catch (e) {}\n\ntry {\n  const cleaned = scoreRaw.replace(/^```(?:json)?\\s*/i, '').replace(/\\s*```\\s*$/i, '').trim();\n  scores = JSON.parse(cleaned).scores || [];\n} catch (e) {}\n\nconst byId = (arr, id) => arr.find(x => x.id === id) || {};\n\nconst merged = variants.map(v => {\n  const c = byId(critiques, v.id);\n  const s = byId(scores, v.id);\n  return {\n    id: v.id,\n    headline: v.headline,\n    body: v.body,\n    cta: v.cta,\n    hook: Number(s.hook) || 0,\n    icp_fit: Number(s.icp_fit) || 0,\n    brand_voice: Number(s.brand_voice) || 0,\n    cta_clarity: Number(s.cta_clarity) || 0,\n    overall: Number(s.overall) || 0,\n    verdict: s.verdict || 'No verdict returned',\n    top_fix: s.top_fix || '',\n    strongest_line: c.strongest_line || '',\n    weakest_line: c.weakest_line || '',\n    hook_critique: c.hook_critique || '',\n    submittedAt,\n    platform\n  };\n});\n\nmerged.sort((a, b) => b.overall - a.overall);\nmerged.forEach((v, i) => { v.rank = i + 1; });\n\n// Return one item per variant so Sheets append fires per variant\nreturn merged.map(v => ({ json: v }));"
      },
      "typeVersion": 2
    },
    {
      "id": "a1b2c3d4-0006-4000-8000-000000000012",
      "name": "Append Scores to Google Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1620,
        208
      ],
      "parameters": {
        "columns": {
          "value": {
            "id": "={{ $json.id }}",
            "cta": "={{ $json.cta }}",
            "body": "={{ $json.body }}",
            "hook": "={{ $json.hook }}",
            "rank": "={{ $json.rank }}",
            "icp_fit": "={{ $json.icp_fit }}",
            "overall": "={{ $json.overall }}",
            "top_fix": "={{ $json.top_fix }}",
            "verdict": "={{ $json.verdict }}",
            "headline": "={{ $json.headline }}",
            "platform": "={{ $json.platform }}",
            "brand_voice": "={{ $json.brand_voice }}",
            "cta_clarity": "={{ $json.cta_clarity }}",
            "submittedAt": "={{ $json.submittedAt }}",
            "weakest_line": "={{ $json.weakest_line }}",
            "strongest_line": "={{ $json.strongest_line }}"
          },
          "schema": [
            {
              "id": "submittedAt",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "submittedAt",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "platform",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "platform",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "rank",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "rank",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "id",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "id",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "headline",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "headline",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "body",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "body",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "cta",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "cta",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "overall",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "overall",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "hook",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "hook",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "icp_fit",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "icp_fit",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "brand_voice",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "brand_voice",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "cta_clarity",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "cta_clarity",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "verdict",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "verdict",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "top_fix",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "top_fix",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "strongest_line",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "strongest_line",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "weakest_line",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "weakest_line",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": []
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Ad Copy Scores"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_YOUR_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "a1b2c3d4-0006-4000-8000-000000000013",
      "name": "Return Ranked Variants Response",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        1620,
        432
      ],
      "parameters": {
        "options": {},
        "respondWith": "json",
        "responseBody": "={{ { ok: true, ranked: $items('Parse and Sort Scores').map(i => i.json) } }}"
      },
      "typeVersion": 1.1
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Parse and Sort Scores": {
      "main": [
        [
          {
            "node": "Append Scores to Google Sheets",
            "type": "main",
            "index": 0
          },
          {
            "node": "Return Ranked Variants Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Variants Received": {
      "main": [
        [
          {
            "node": "Prepare Evaluation Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Variants Data": {
      "main": [
        [
          {
            "node": "Post to Claude for Critic Notes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post to Claude for Scoring": {
      "main": [
        [
          {
            "node": "Parse and Sort Scores",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Evaluation Settings": {
      "main": [
        [
          {
            "node": "Normalize Variants Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post to Claude for Critic Notes": {
      "main": [
        [
          {
            "node": "Post to Claude for Scoring",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}