Spintax for n8n: unique copy per lead — and per product

n8n is where your lead list or product feed already lives as rows. n8n-nodes-spintax adds one node that turns each row into finished text: a cold email per lead, a product description per storefront — same flow, different feed. This guide walks you through it: install, your first render, the lead bridge, the product variant, and the loop where your own LLM writes the template.

One flow, two feeds

Everything below rests on one rule: every top-level string, number or boolean field of an incoming item is already a %variable% — you map nothing. Feed the flow a lead list and it writes a subject and body per lead. Feed it a product feed and it writes a description per product. The template is the asset; the feed is interchangeable.

Lead listSpintax Render per rowProduct feedYour sending toolYour storefronts
One flow, two feeds: every row renders to finished text

Install the node

In your n8n, open Settings → Community Nodes → Install, enter n8n-nodes-spintax, confirm. That is the whole setup: the node bundles the @spintax/core engine, asks for no credentials, and never calls out of your instance. (Community nodes install where the instance admin allows them; on n8n Cloud the picker carries verified nodes.)

If you would rather start from a working canvas, import a ready-made workflow via ⋯ → Import from URL… — two accompany the node in its repository, both verified against a live n8n before release:

  • Cold-email bridge — leads (or a product feed) in, a unique subject and body per row out.
  • AI authoring funnel — your LLM drafts the template; the flow validates, repairs and renders it.

Your first render

  1. Add a Spintax node. The operation is already Render.
  2. Paste a template into the Template field:
{Quick|One|Small} {question|idea} for %company% — the %product% {listing|page}
  1. Run it on items that carry company and product fields — a Google Sheet, a CRM export, anything that produces rows.

Each item comes out with a rendered field (rename it in Output Field):

Brightline Gear → "Small idea for Brightline Gear — the Trekker 45L backpack page"
Nordic Home    → "Quick question for Nordic Home — the cast-iron skillet set page"

Those two came out different because the Seed field held an expression, {{ $json.company }}:subject: each row draws its own variant, and the variant is stable — re-run the workflow and nobody's copy reshuffles, so you can always show what a given lead received. Change the suffix when you want to re-roll everyone. Leave Seed empty for a fresh draw per run.

Wire it into your outreach

Why render inside n8n at all? Because your sending platform will not do it: Instantly, Smartlead and the rest parse flat {a|b|c} at best, while permutations, conditionals and plural agreement take one measured five-line email from 59,049 combinations to 8.5 million. So render first, send finished text. The bridge is three nodes:

  1. Your lead source — Google Sheets, Airtable, a CRM export.
  2. Two Render nodes: one writes subject, one writes body, each with a per-row seed as above.
  3. Your sending tool's own n8n node — map subject and body into it, done.

The cold-email bridge workflow above is exactly this, ready to import; swap its sample Code node for your real source.

Same flow, product feed

Now swap the lead list for a product feed. Change nothing else — but let the template use the fields a catalogue actually has. Paste this one:

{?has_discount?With the {current|running} promo at %price%, the|The} %product% {deserves|warrants} {fresh|distinct} copy per {storefront|channel} — %in_stock% {plural %in_stock%: unit|units} in stock.

in_stock: 14, has_discount: yes
→ "With the current promo at $129, the Trekker 45L backpack deserves distinct copy per storefront — 14 units in stock."

in_stock: 1, has_discount: (empty)
→ "The cast-iron skillet set deserves distinct copy per storefront — 1 unit in stock."

Read what happened: the promo sentence exists only for rows where has_discount is set, and "1 unit" never comes out as "1 units". You wrote neither an if-node nor a grammar check — the conditional and the plural form are part of the template.

Selling the same catalogue on several storefronts? Run the feed once per site and put the site into the seed: {{ $json.sku }}:site-a, then :site-b. Each storefront gets its own stable variant of every description — same data, distinct text everywhere, which is the defence against duplicate-content penalties made mechanical. Why templated variation beats letting a model write every page: What is spintax?; what the model route costs: What AI content actually costs.

Let your LLM write the template

Writing a rich template by hand is the part people skip — so don't. Import the AI authoring funnel workflow and connect exactly one thing: credentials on its model node, or swap that node for any LLM you already run. The Spintax nodes are provider-agnostic: they hand your model plain systemPrompt/userPrompt text and read plain text back.

ValidInvalidBriefBuild Authoring PromptYour LLM nodeValidateRender / Render ManyBuild Repair PromptLLM repairEmail · Telegram · CRM
The authoring funnel — the ready-made workflow caps the loop at one repair round

Fill in your brief ("a short outreach email for %first_name% at %company%…"), list the variables the model may use — with a grammatical case per variable if you write in an inflected language — and run. Build Authoring Prompt emits the canonical prompt the whole spintax ecosystem shares (versioned in promptVersion). Validate routes the draft to its Valid or Invalid output; on Invalid, Build Repair Prompt points your model at the exact offending line and column and sends it back — the workflow caps that loop at one round, which is usually enough. On Valid, Render Many gives you five variants to read.

Three settings worth knowing before you run it:

  • Clean Model Output — turn it on for Validate. Models wrap replies in code fences no matter what the prompt says; this strips them into cleanedTemplate, and every diagnostic position points into exactly that text.
  • Locale — set it once, in Build Authoring Prompt. It rides the item as spintaxMeta, and every later node reads it from there: leave the Locale fields of Validate, Render and Render Many empty. (Validate passes spintaxMeta through on both outputs; your LLM node drops unknown fields, which is exactly why the chain stays unbroken.)
  • Count on Render Many (default 5, up to 100, plus a Max Attempts budget where 0 means automatic) — and read produced next to requested in the output. Distinct seeds are independent draws, not distinct results: a low-variety template may simply not have five variants to give — {Fast|Quick} delivery yields two however many you request — and the node tells you so instead of retrying forever or silently under-delivering.
  • Base Seed, if you keep what you generate. Every variant then carries the seed that produced it in attemptSeed — save that field with the text. It is not the row number: as soon as two draws collide, the counter and the position part ways, and the seed is the only thing that rebuilds that one document later.

Check what actually came out

Validate judges the template. It cannot judge the render — and a flawless template still turns out the odd broken line, because that defect lives in the combination of choices rather than in the source: two neighbouring slots pick the same word, a noun from one slot meets a pronoun from another, an unlucky join leaves a space before a comma. Nothing is wrong in the template; something is wrong in one row out of fifty, and fifty rows is already more than anyone re-reads. Add a Lint node after your render and the defective rows leave on its second output while the clean ones carry on. Point it at a template instead of a rendered row and it draws a sample itself, then tells you how many documents came out clean — that is the number to fix slots against, before the real run rather than after it.

Then there is the question about the batch as a whole, the one exact-string dedupe cannot answer: are these documents actually different, or is it one skeleton wearing fifty hats? Uniqueness reads every incoming item as one pool, drops the near-duplicates and reports a footprint — the share of the pool's five-word windows that repeat across it. Measured on real pools of the same size, one template scores about 0.96 and six templates about 0.02; the counter-intuitive part is that asking for more variants of the same template cannot move it, because the skeleton is fixed by the template and re-rendering dilutes nothing. Only new templates — or denser variation inside the one you have — bring the number down. And one setting decides whether the number means anything at all: put the strings every row repeats (a product name, a merge tag) into Shared Strings, or the metric ends up measuring your product name instead of your writing.

When something else expands the text after you

Sometimes the render is not the last step: the copy goes on to Mailchimp merge tags, to Liquid, to your CRM's own macros. Those syntaxes collide with this one — %name% is a variable here and a macro there, square brackets are permutation syntax so a bracketed macro comes out without them, and the cosmetic pass will happily put a space inside a macro parameter. Wrap the render in Protect Placeholders: in Protect mode it swaps the foreign strings for markers the render cannot touch, in Restore mode it puts them back byte-exact and checks the round trip. It refuses loudly when something does not add up — including the trap that looks like nothing at all, a variable of yours named like a macro of theirs, which our engine expands before the other one ever sees it and leaves you with a plausible, wrong document.

What you don't have to babysit

Scraped data is safe by default: a company name containing { or | renders as text, never as markup — incoming values pass through the engine's shield before the template sees them. Variables you type into the node yourself are trusted, so a deliberate {Mr|Ms} keeps working; each entry has its own shield toggle for the day you paste external data into one. And nothing here phones home: no credentials, no network calls, no filesystem — the render happens on your instance.

Before the real run

  • Seed is a per-row expression, so a re-run cannot reshuffle sent copy.
  • Locale is set — on the Render node, or once in Build Authoring Prompt for the funnel.
  • produced equals requested on Render Many — or you know why not.
  • You read twenty rendered rows, not one — or a Lint node read all of them for you.
  • If what you generated is a pool rather than one row per lead, Uniqueness says it is varied, not merely free of duplicates.

Take the template anywhere

The template you built here is portable by contract: the same syntax, validation verdicts and plural semantics ship in the JavaScript, PHP, Python and Object Pascal engines, held to one shared conformance corpus. Test it in the playground, batch-export from Spintax Studio, render it in WordPress — the template is the asset, engines are interchangeable runtimes. And until your sending or e-commerce platform adopts the syntax natively, this node is the no-code route to running it.

The constructs, in full

Permutations for order and length. Conditionals for data-driven lines. Plural agreement for counted nouns. And the AI-to-template workflow the authoring funnel automates.