Back to n8n Workflows

Fan Out Creative Testing

Mike Taylor Mike Taylor
Ad Testing

Automatically generate 8 ad creative variations from your original concept, then test all 9 versions with AI personas to predict CTR performance. Get instant creative testing results without spending a penny on real ads.

Fan Out Creative Testing n8n workflow diagram

Click to expand

Overview

This workflow revolutionizes ad creative testing by automatically generating multiple ad variations and instantly testing them with AI personas to predict performance. Starting with a single headline and description, it uses Google Gemini to create 8 creative variations that maintain the core message while exploring different angles. These variations are then tested against Rally's AI audience using voting mode to simulate real user choice behavior, with results ranked by predicted click-through rates. Perfect for marketers who need to rapidly test creative concepts before committing to ad spend.

🎯 Pro Tips & Secret Sauce

The magic lies in the dual AI system for creative generation and performance prediction:

  1. Intelligent Creative Expansion - Google Gemini doesn't just rewrite ads randomly; it understands the core value proposition and explores different creative angles while maintaining message integrity and respecting character limits
  2. Structured Output Validation - Uses JSON schema validation to ensure all 9 variations (original + 8 new) are properly formatted with exactly 30-character headlines and 90-character descriptions
  3. Simulated Choice Architecture - Rally's voting mode presents all variations as a realistic Google Ads scenario ("Which would you click?") rather than rating each individually, mimicking real user behavior
  4. Automatic Performance Ranking - CTR calculation and sorting reveals which creative approaches resonate most with the target audience, eliminating guesswork about which variations to test first
  5. Zero-Cost Creative Testing - Get comprehensive creative performance insights before spending any ad budget, dramatically reducing the risk of poor-performing creative concepts

This creates a complete creative development and testing pipeline that would typically require weeks of work and thousands in ad spend, compressed into minutes of automated analysis.

πŸ“ Step-by-Step Instructions

  1. Form Submission - User enters brand description, original headline, and description through the web form interface
  2. Generate Creative Variations - Google Gemini analyzes the brand context and original ad to create 8 new creative variations that explore different angles while maintaining the core message
  3. Structure Output Validation - JSON schema parser ensures all 9 variations (original + 8 new) meet Google Ads requirements with proper headline and description length limits
  4. Rally Audience Testing - Sends all 9 ad variations to Rally's AI personas in voting mode, presenting them as realistic Google search ads asking "Which would you click?"
  5. Extract Individual Votes - Splits Rally's response array to process each persona's individual voting decision for detailed analysis
  6. Calculate Click-Through Rates - Custom code processes all votes, counts selections for each variation, calculates percentages, and marries results with original ad content
  7. Display Performance Report - Formats results into a ranked HTML report showing each variation's headline, description, and predicted CTR sorted by performance from highest to lowest

πŸ“‹ Requirements

Required Integrations

  • Google Gemini - AI language model for generating creative ad variations with structured output
  • Rally API - AI persona testing service for simulating real user ad selection behavior
  • Form Trigger - Web interface for inputting brand information and original ad creative
  • LangChain - Framework for chaining LLM operations with structured output parsing (n8n native)

Required Credentials

  • Google Gemini (PaLM) API credentials for creative generation
  • Rally API Bearer token with voting mode access
  • n8n instance with LangChain nodes enabled

Setup Prerequisites

  • Active Rally account with configured audience personas (default: rb842b547c27640)
  • Google AI/Gemini API access with sufficient quotas
  • n8n instance with LangChain integration capabilities
  • No external storage or additional services required

πŸš€ n8n Workflow Template

{
  "active": false,
  "connections": {
    "Ask Rally": {
      "main": [
        [
          {
            "index": 0,
            "node": "Get Individual Votes",
            "type": "main"
          }
        ]
      ]
    },
    "Basic LLM Chain": {
      "main": [
        [
          {
            "index": 0,
            "node": "Ask Rally",
            "type": "main"
          }
        ]
      ]
    },
    "Calculate CTR": {
      "main": [
        [
          {
            "index": 0,
            "node": "Display Report",
            "type": "main"
          }
        ]
      ]
    },
    "Get Individual Votes": {
      "main": [
        [
          {
            "index": 0,
            "node": "Calculate CTR",
            "type": "main"
          }
        ]
      ]
    },
    "Google Gemini Chat Model": {
      "ai_languageModel": [
        [
          {
            "index": 0,
            "node": "Basic LLM Chain",
            "type": "ai_languageModel"
          }
        ]
      ]
    },
    "On form submission": {
      "main": [
        [
          {
            "index": 0,
            "node": "Basic LLM Chain",
            "type": "main"
          }
        ]
      ]
    },
    "Structured Output Parser": {
      "ai_outputParser": [
        [
          {
            "index": 0,
            "node": "Basic LLM Chain",
            "type": "ai_outputParser"
          }
        ]
      ]
    }
  },
  "id": "Tl8197fxk9xDTBIK",
  "meta": {
    "instanceId": "7aa2e96d57ff40383569724f8ecb13d674a87bf09d39aa5d8fa5ba31f7a8407a",
    "templateCredsSetupCompleted": true
  },
  "name": "Fan Out Creative Testing",
  "nodes": [
    {
      "id": "c6fb516a-aa31-4d5b-9c9c-9f308a5b79c0",
      "name": "On form submission",
      "parameters": {
        "formDescription": "Enter your creative idea",
        "formFields": {
          "values": [
            {
              "fieldLabel": "Brand",
              "fieldType": "textarea",
              "placeholder": "Ladder is a growth marketing agency that has run 10,000 experiments across all major growth channels. We test and learn what works so you don\u0027t have to. We have offices in New York, London, and across the EU. We have worked with clients such as Monzo Bank, Booking.com, and Time Out Magazine."
            },
            {
              "fieldLabel": "Headline",
              "placeholder": "Growth without the guesswork",
              "requiredField": true
            },
            {
              "fieldLabel": "Description",
              "placeholder": "We\u0027ve run 10,000 experiments to learn what works so you don\u0027t have to ",
              "requiredField": true
            }
          ]
        },
        "formTitle": "Ask anything",
        "options": {},
        "responseMode": "lastNode"
      },
      "position": [
        -480,
        -60
      ],
      "type": "n8n-nodes-base.formTrigger",
      "typeVersion": 2.2,
      "webhookId": "6c20a59e-075a-4070-850e-95353cccabf9"
    },
    {
      "credentials": {
        "httpBearerAuth": {
          "id": "HeUQ9K9oUtQ5TyHr",
          "name": "Bearer Auth account"
        }
      },
      "id": "1be487f1-f971-4ee9-b199-16aa4d39ab35",
      "name": "Ask Rally",
      "parameters": {
        "authentication": "genericCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "smart",
              "value": "false"
            },
            {
              "name": "provider",
              "value": "google"
            },
            {
              "name": "query",
              "value": "=You\u0027re searching on Google and you see the following ads. Which would you click?:\n\n{{ $json.output.map((ad, index) =\u003e `${String.fromCharCode(97 + index)}) ${ad.Headline}: ${ad.Description}`).join(\u0027\\n\\n\u0027) }}"
            },
            {
              "name": "audience_id",
              "value": "rb842b547c27640"
            },
            {
              "name": "voting_mode",
              "value": "true"
            }
          ]
        },
        "genericAuthType": "httpBearerAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "accept",
              "value": "application/json"
            }
          ]
        },
        "method": "POST",
        "options": {},
        "sendBody": true,
        "sendHeaders": true,
        "url": ""
      },
      "position": [
        340,
        -60
      ],
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2
    },
    {
      "id": "7e2a7f01-31bb-4ac1-975c-494337172639",
      "name": "Basic LLM Chain",
      "parameters": {
        "batching": {},
        "hasOutputParser": true,
        "promptType": "define",
        "text": "=Come up with 8 new very creative variations on this Google Ad by changing the Headline, Description or both. Do not deviate from the core creative idea, just express it in more creative ways. Headlines in text ads can be up to 30 characters each, while descriptions can be 90 characters each. Only respond with an array of Headline and Description combinations. Always start with the original Headline and Description you were given as the first in the list, then add your 8 new variations after.\n\n## Headline and Description\nHeadline: {{ $json.Headline }}\nDescription: {{ $json.Description }}\n\n## Brand\nHere is more information about the brand, you can use any of this so long as the core creative idea remains the same:\n{{ $json.Brand }}\n\n## Examples\nHere are some examples to show you what the task looks like when done well. You should not use these examples in your output, always make new variations relevant to the brand. \n\n**Original Ad:**\n- Headline: \"Fresh Coffee Daily\"\n- Description: \"Artisan roasted beans from around the world. Visit our cozy downtown location today!\"\n- Brand: \"Bean \u0026 Brew Coffee House - A family-owned coffee shop serving the community since 1985. Known for our warm atmosphere, friendly baristas, and commitment to fair trade coffee.\"\n\n**Output:**\n\n```json\n[\n  {\n    \"Headline\": \"Fresh Coffee Daily\",\n    \"Description\": \"Artisan roasted beans from around the world. Visit our cozy downtown location today!\"\n  },\n  {\n    \"Headline\": \"Artisan Coffee Hub\", \n    \"Description\": \"From bean to cup perfection. Join our coffee family at the heart of downtown!\"\n  },\n  {\n    \"Headline\": \"Your Daily Brew Fix\",\n    \"Description\": \"Global beans, local love. Discover your new favorite spot in downtown today!\"\n  },\n  {\n    \"Headline\": \"Coffee Craft Masters\",\n    \"Description\": \"Handcrafted perfection in every cup. Visit our warm, welcoming coffee sanctuary!\"\n  },\n  {\n    \"Headline\": \"Bean Perfection Daily\",\n    \"Description\": \"Fair trade excellence meets downtown charm. Taste the difference quality makes!\"\n  },\n  {\n    \"Headline\": \"Roasted Fresh Today\",\n    \"Description\": \"Today\u0027s fresh roast awaits you. Step into our cozy community coffee corner!\"\n  },\n  {\n    \"Headline\": \"Coffee Art \u0026 Heart\",\n    \"Description\": \"Where passion meets the perfect cup. Experience our family tradition downtown!\"\n  },\n  {\n    \"Headline\": \"Premium Beans Daily\",\n    \"Description\": \"Exceptional beans, exceptional experience. Your new downtown coffee home awaits!\"\n  }\n]\n```"
      },
      "position": [
        -160,
        -60
      ],
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7
    },
    {
      "credentials": {
        "googlePalmApi": {
          "id": "cWM5zhRjuahlwVKK",
          "name": "Google Gemini(PaLM) Api account"
        }
      },
      "id": "a4676bde-62da-4917-844a-3a9c5d81ab3f",
      "name": "Google Gemini Chat Model",
      "parameters": {
        "modelName": "models/gemini-2.5-flash",
        "options": {}
      },
      "position": [
        -180,
        160
      ],
      "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
      "typeVersion": 1
    },
    {
      "id": "1dc2e11f-19a5-4236-b336-32e71697fd2b",
      "name": "Structured Output Parser",
      "parameters": {
        "inputSchema": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"type\": \"array\",\n  \"title\": \"Google Ad Variations\",\n  \"description\": \"An array of creative Google Ad headline and description combinations\",\n  \"items\": {\n    \"type\": \"object\",\n    \"properties\": {\n      \"Headline\": {\n        \"type\": \"string\",\n        \"maxLength\": 30,\n        \"description\": \"The ad headline, maximum 30 characters\"\n      },\n      \"Description\": {\n        \"type\": \"string\",\n        \"maxLength\": 90,\n        \"description\": \"The ad description, maximum 90 characters\"\n      }\n    },\n    \"required\": [\"Headline\", \"Description\"],\n    \"additionalProperties\": false\n  },\n  \"minItems\": 9,\n  \"maxItems\": 9,\n  \"uniqueItems\": true\n}",
        "schemaType": "manual"
      },
      "position": [
        40,
        160
      ],
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "typeVersion": 1.3
    },
    {
      "id": "43b5a382-2d7e-40ec-95fc-1a67335f4044",
      "name": "Sticky Note",
      "parameters": {
        "color": 5,
        "content": "## Synthetic Testing\nTake 8 new variations and test them against the original.",
        "height": 500,
        "width": 540
      },
      "position": [
        260,
        -160
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    },
    {
      "id": "1df2417b-1f17-483f-a402-b593ee1db835",
      "name": "Sticky Note1",
      "parameters": {
        "content": "## Fan Out Creative\nGenerate 8 new creative variations based on the original.",
        "height": 500,
        "width": 540
      },
      "position": [
        -300,
        -160
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    },
    {
      "id": "40925b49-241d-442b-806e-04a5a4323953",
      "name": "Get Individual Votes",
      "parameters": {
        "fieldToSplitOut": "responses",
        "options": {}
      },
      "position": [
        560,
        -60
      ],
      "type": "n8n-nodes-base.splitOut",
      "typeVersion": 1
    },
    {
      "id": "a8b1cb15-1d95-4f83-9855-526e110302de",
      "name": "Calculate CTR",
      "parameters": {
        "jsCode": "// Count occurrences of each option\nlet optionCounts = {};\nlet total = 0;\n\nfor (const item of $input.all()) {\n    try {\n        // Parse the response JSON\n        const responseData = JSON.parse(item.json.response);\n        \n        // Get the option value\n        const option = responseData.option;\n        \n        if (option) {\n            total++;\n            // Initialize count for this option if it doesn\u0027t exist\n            if (!optionCounts[option]) {\n                optionCounts[option] = 0;\n            }\n            optionCounts[option]++;\n        }\n    } catch (error) {\n        // Handle cases where response is not valid JSON\n        console.log(`Error parsing response for item ${item.json.persona_id}:`, error.message);\n    }\n}\n\n// Get ad variations using n8n syntax\nlet adVariations = [];\ntry {\n    adVariations = $(\u0027Basic LLM Chain\u0027).first().json.output;\n} catch (error) {\n    console.log(\"Error getting ad variations:\", error.message);\n}\n\n// Marry up options with ad variations and calculate percentages\nconst variationsWithAds = [];\n\nfor (const [option, count] of Object.entries(optionCounts)) {\n    const percentage = total \u003e 0 ? (count / total * 100).toFixed(1) : 0;\n    \n    // Convert option letter to index (a=0, b=1, c=2, etc.)\n    const optionIndex = option.charCodeAt(0) - 97; // \u0027a\u0027.charCodeAt(0) = 97\n    \n    // Get the corresponding ad variation\n    const adVariation = adVariations[optionIndex] || { Headline: \"Unknown\", Description: \"Unknown\" };\n    \n    variationsWithAds.push({\n        option: option,\n        count: count,\n        percentage: parseFloat(percentage),\n        headline: adVariation.Headline,\n        description: adVariation.Description\n    });\n    \n    console.log(`Option \u0027${option}\u0027: ${adVariation.Headline} - ${count} clicks (${percentage}%)`);\n}\n\n// Sort by CTR percentage descending\nvariationsWithAds.sort((a, b) =\u003e b.percentage - a.percentage);\n\nconsole.log(`\\nTotal responses: ${total}`);\nconsole.log(`CTR by variation (sorted by performance):`);\nvariationsWithAds.forEach(v =\u003e {\n    console.log(`  ${v.option.toUpperCase()}) ${v.headline}: ${v.description} | CTR: ${v.percentage}%`);\n});\n\n// Return comprehensive results\nreturn {\n    total: total,\n    variationsWithAds: variationsWithAds\n};"
      },
      "position": [
        340,
        180
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    },
    {
      "id": "2194d5a2-caf2-40a7-a5c5-bc97dc6e01fe",
      "name": "Display Report",
      "parameters": {
        "operation": "completion",
        "respondWith": "showText",
        "responseText": "=\u003ch1\u003e{{ $json.title || \u0027Ad Variation CTR Results\u0027 }}\u003c/h1\u003e\n\u003cp\u003e\u003cstrong\u003eTotal Responses:\u003c/strong\u003e {{ $json.total }}\u003c/p\u003e\n\u003cbr\u003e\n\u003cp\u003e-----\u003c/p\u003e\n\u003chr\u003e\n\u003cbr\u003e\n\u003c!-- each variation with ad content, sorted by CTR descending --\u003e\n{{ $json.variationsWithAds\n     .map(v =\u003e `\u0026bull;\u0026nbsp;\u003cstrong\u003e${v.option}) ${v.headline}:\u003c/strong\u003e ${v.description} \u003cbr\u003e \u003cstrong\u003eCTR: ${v.percentage}%\u003c/strong\u003e\u003cbr\u003e\u003cbr\u003e`)\n     .join(\u0027\u0027) }}"
      },
      "position": [
        560,
        180
      ],
      "type": "n8n-nodes-base.form",
      "typeVersion": 1,
      "webhookId": "205dca97-541a-4635-9957-4644c6400fa2"
    }
  ],
  "pinData": {
    "On form submission": [
      {
        "json": {
          "Brand": "Ladder is a growth marketing agency that has run 10,000 experiments across all major growth channels. We test and learn what works so you don\u0027t have to. We have offices in New York, London, and across the EU. We have worked with clients such as Monzo Bank, Booking.com, and Time Out Magazine.",
          "Description": "We\u0027ve run 10,000 experiments to test and learn what works so you don\u0027t have to.",
          "Headline": "Growth without the guesswork",
          "formMode": "test",
          "submittedAt": "2025-06-21T07:50:30.672-04:00"
        }
      }
    ]
  },
  "settings": {
    "executionOrder": "v1"
  },
  "tags": [],
  "versionId": "dab232cf-99e1-419c-a2ad-33562adc0db6"
}

About the Author

Mike Taylor

Mike Taylor

Mike Taylor is the CEO & Co-Founder of Rally. He previously co-founded a 50-person growth marketing agency called Ladder, created marketing & AI courses on LinkedIn, Vexpower, and Udemy taken by over 450,000 people, and published a book with O’Reilly on prompt engineering.