Automating a Grammar Variable with an AI in n8n¶
Some bugs are obvious right away: an email that won't send, a 500 error. And then there are the ones that hide inside the text itself, invisible until someone reads the sentence out loud. This second category is what this session was about: an empty personalization variable in Lemlist campaigns, and the fix built to replace it, a small n8n sub-workflow that calls an LLM to write... three words of French grammar.
It sounds trivial. It wasn't entirely, and the way it went sideways (twice) is probably more interesting than the fix itself.
The problem: a sentence with a hole in it¶
On outbound sales campaigns (Lemlist), every email is personalized with variables like {{firstName}}, {{companyName}}. Nothing unusual there. But some sentences need more than a raw name:
"Comment sécurisez-vous les communications {{companyDu}} ?"
The expected result: "Comment sécurisez-vous les communications de la MACIF ?" or "...d'Allianz France ?" or "...de CNP Assurances ?". {{companyDu}} isn't the company name: it's the preposition and the name, ready to be inserted as-is. Without this variable filled in, the email goes out with a gaping hole in the sentence, or worse, with the raw, unresolved variable staring back at the prospect.
Until recently, this variable was computed by hand, with the help of an AI assistant in the session, every time leads were imported from Apollo into Lemlist. That manual work disappeared once this step became fully automated: the lead-creation form and the automatic Apollo enrichment are now both driven directly by n8n workflows, with no manual session left in between. Result: nobody computes companyDu by hand anymore, and new leads arrive in campaigns with that field empty.
The spark
It wasn't an audit that caught the problem first: it was a campaign preview in Lemlist that literally showed an unresolved {{companyDu}} in the subject line of a test email. The kind of detail you only catch by reading it back, never by coding.
The investigation: don't trust a single example¶
The first attempt at a fix was based on a single case that had already been solved by hand: three leads (MACIF, Allianz France, CNP Assurances) for which someone had manually filled in companyDu in an earlier conversation. A grammar rule was extrapolated from those three examples, a sub-workflow was built, tested, and shipped.
Wrong approach. After being pointed toward looking at how the variable is actually used in the campaigns, the right method became obvious: read the real content of the email sequences instead of relying on a memory of a conversation.
An audit of the 32 active campaigns on the Lemlist account followed (via the API, searching every {{company...}} pattern in email subjects and bodies). Result: there isn't one variable of this kind, but two, always used together in the same sequences.
| Variable | Meaning | Example sentence | Expected rendering |
|---|---|---|---|
{{companyDu}} |
"de/du/de la/d'" + company | "les communications {{companyDu}}" | "les communications de la MACIF" |
{{companyAu}} |
"à/au/à la" + company | "voir si ça s'applique {{companyAu}}" | "voir si ça s'applique à la MACIF" |
companyDu appears in 13 campaigns, companyAu in 3; but everywhere companyAu shows up, companyDu is there too, in the same sequence. This isn't an accidental duplicate: these are two forms of the same grammatical analysis (the implicit gender of the company name), used in different spots in the sentence depending on which preposition the verb calls for.
Reality check
Are these variables listed as "custom fields" on the Lemlist side (the tool exposes an endpoint for that)? Answer: no. companyDu/companyAu are lead variables, set case by case via the API, not fields registered at the team level. One more place where the tool doesn't tell the whole story: I had to go read the actual content produced, not the metadata describing the structure.
Why an LLM, and not a simple rule¶
The developer's temptation: write a deterministic function. if name.startswith(vowel) return "d'" + name. The problem is acronyms and initialisms pronounced like common nouns, the most frequent case across the target sectors (mutual insurers, banks, insurance companies, local government). "MACIF" is spoken as "la MACIF" (like "la Sécu"), "CIC" is spoken as "le CIC". This implicit gender isn't in the name itself: it comes from cultural knowledge of what the acronym stands for. English doesn't have this problem at all: nouns aren't grammatically gendered, so there's no equivalent puzzle to solve. But that's exactly the kind of context-dependent linguistic judgment call an LLM handles well, which made the choice easy: a regex can't guess the gender, a general-purpose model already knows it for nearly every organization on the target list.
Hence the decision: an AI agent (via OpenRouter, a lightweight model: plenty for this task, no need for a reasoning model to produce three words of grammar), with forced structured output (JSON {companyDu, companyAu}) to avoid any stray text in the response.
flowchart LR
A["companyName (input)"] --> B{"Name empty?"}
B -->|yes| C["companyDu = ''\ncompanyAu = ''"]
B -->|no| D["AI Agent (OpenRouter)"]
D --> E["Structured output\n{companyDu, companyAu}"]
E --> F["Cleanup / trim"]
C --> G(("Return"))
F --> G
The sub-workflow skips the LLM call entirely when companyName is empty, no point paying for an API call that produces nothing.
Writing the prompt, and its first framing mistake¶
First draft of the system prompt:
"Ta tâche : produire la forme correcte pour introduire un nom d'entreprise après la préposition 'de', comme dans 'le contact de la MACIF'..."
It looks clear. It isn't. This phrasing implies that the word "de" is already written in the template, and that the model just needs to produce what comes after it. But that's not the case: in the actual text, there's no hardcoded preposition at all, just {{companyDu}} sitting alone in the middle of the sentence. If the model takes the prompt literally, it might return just "la MACIF" (thinking "de" is handled elsewhere), and the final sentence becomes "les communications la MACIF", grammatically broken.
It was only by holding this phrasing up against the real campaign text that the problem became visible. The prompt was rewritten to remove all ambiguity:
Contexte important : dans nos templates d'emails, AUCUNE préposition
n'est écrite en dur avant la variable. Le texte est par exemple
"les communications {{companyDu}}" (et non "de {{companyDu}}").
Cela signifie que TU dois produire la préposition ET le nom ensemble :
la valeur que tu renvoies est insérée telle quelle dans la phrase.
Ne réponds JAMAIS avec seulement le nom de l'entreprise ou
seulement un article sans préposition.
The lesson
When writing a prompt to fill in a template variable, quote the real text surrounding that variable, never paraphrase it. "After the preposition de" is a paraphrase that assumes a structure which doesn't actually exist. Pasting the real sentence ("les communications {{companyDu}}") into the prompt kills the ambiguity at the source.
The bug that never announced itself¶
Here's the most instructive part of the session. Prompt fixed, tests re-run, everything went green ("success" on every execution). Published. Message: "it's fixed."
Reply: "I don't see any change."
Reopening the node in the n8n editor still showed the old text. Not a browser cache issue: the node's actual content genuinely had never changed.
A JSON path relative to the wrong object
Digging in, the cause: the tool used to edit n8n workflows accepts a JSON path ("JSON Pointer") to target a specific field inside a node's parameters. That path is relative to the node's parameters object, not to the whole node. The path used was /parameters/options/systemMessage instead of /options/systemMessage.
The concrete result: instead of modifying node.parameters.options.systemMessage (the field n8n actually reads), the call created a new key literally called parameters, nested inside the existing parameters:
{
"parameters": {
"promptType": "define",
"options": { "systemMessage": "OLD TEXT" },
"parameters": {
"options": { "systemMessage": "NEW TEXT" }
}
}
}
The new text really did exist in the workflow, just in the wrong place, inside a key that n8n silently ignores. No validation error, no warning. The tool replied "success" on every call, because from a strictly technical standpoint, it had indeed written what it was asked to write, at the wrong path.
A silent failure is worse than a loud one. A clear error would have stopped the process immediately; this one let three successive updates through, including one that added a whole second variable (companyAu), without any of them actually taking effect. The real production prompt stayed frozen on its very first version while what looked like iteration kept happening on top of it. The same kind of n8n node-editing trap showed up on another project: see the post-mortem in the Apollo/Lemlist enrichment piece.
The fix: replace targeted editing with a full, explicit replacement of the node's parameters object, then re-read the workflow's actual state afterward to verify, instead of settling for the absence of a returned error. It was by comparing the content returned by that re-read (which correctly distinguished the node's "draft" from its "published active version") that the duplicated structure became visible.
Wiring the fix in without breaking what already works¶
Once the sub-workflow was reliable, the next step was wiring it into the two workflows that create or update leads. A technical constraint had to be respected: in n8n, an "Execute Sub-workflow" node entirely replaces the current item's data with whatever the sub-workflow returns. Insert it directly into the main chain, and every field that existed before the call (email, name, job title...) gets wiped out.
The standard fix: branch the sub-workflow call in parallel with the main chain, then merge the two branches back together with a merge node (by position) before continuing.
flowchart TD
A["Lead prepared\n(email, name, company...)"] --> B["Branch A\n(passthrough)"]
A --> C["Branch B\ncompanyDu/Au sub-workflow"]
B --> D["Merge (by position)"]
C --> D
D --> E["Create Lemlist lead"]
Second constraint, specific to one of the two workflows: the native Lemlist lead-creation node doesn't accept any custom variable as a parameter: only fixed fields (name, job title, phone...). So companyDu/companyAu have to be set afterward, via a separate HTTP call (PATCH /api/leads/{id}/variables). That second call sits on a side branch that never merges back into the form's completion message: so if that PATCH fails or drags on, it can never crash or delay the confirmation sent to the sales rep who just created the lead.
How to test a prompt you can't actually read¶
A more unexpected constraint: the tool available for triggering a test run in n8n only returns the execution's status (success / error), never the content each node actually produced. So there was no way to read, in black and white, what the model had answered, to eyeball the grammar.
The workaround: the forced structured output (expected JSON schema) acts as an indirect safety net. If the model had returned out-of-format text, or dropped one of the two required fields, the run would have failed at parsing time, not just silently produced a low-quality result. A consistently "successful" status across several test companies (MACIF, Allianz France, CNP Assurances, CIC), including the "empty name" case that's supposed to skip the AI call (execution in ~10 milliseconds, a sign no network call happened), gave a reasonable level of assurance, without replacing an actual human read of the final result in the tool.
What's still worth watching¶
- Two variables today, maybe a third tomorrow. The audit only found
companyDuandcompanyAuacross the current 32 campaigns. If a new campaign introduces a different grammatical construction, it should be added to the same sub-workflow rather than spinning up scattered variants. - One of the two calling workflows is still disabled. The one that updates contacts already present in Lemlist (rather than creating new ones) hasn't been validated with real writes yet: it's waiting on a deliberate activation rather than an automatic one.
- The lesson about JSON paths goes beyond this one case. Since then, every n8n node edit is followed by a full re-read of the workflow's actual state, not just a check that the tool didn't return an error. A "success" response documents what the tool attempted to write, not necessarily what will actually be read at execution time.
Workflows built and published on the internal n8n instance, connected to Lemlist (sales CRM) and OpenRouter (model access). The company names mentioned (MACIF, Allianz France, CNP Assurances, CIC) are test examples carried over from earlier conversations, not data pulled from a production system.
To go further: Automatically enriching Lemlist contacts with Apollo via n8n, the two workflows that call this personalization fix.