Smart Message templates are written in Twig 3. This page lists every variable, function and filter Nonli supports in that context, with its type, when it can be missing, and a tested example. To create a template step by step, start with How to make a Smart Message.
1. Where this context applies#
The same context is available in two places:
- Smart Message templates, used by bots, by the light-bulb button of the post editor and by the first-comment button. They can fill the message, title, description, comment, video title, alt text and TikTok anchor title.
- Smart Image dynamic values, when a Smart Image text or color uses a Smart Message template as its source. In that case the variable
contextequals'smart_image'. See How to use dynamic colors in Smart Image.
Dynamic URL parameters and the dynamic paywall also use Twig, but with a much smaller context: a restricted post (source, social_account, post_type, author, user_id, breaking_news, short_id), referer, the isComment() function, and counters and percentiles. organic, the top-level aliases and the Smart Message functions below do not exist there.
2. Naming and type rules#
- Prefer full paths. Write
organic.titleorpost.social_account.typerather than a top-level alias. The aliases remain supported for existing templates. - Names are snake_case and follow the stored field names:
canonical_url,published_at,open_graph. - Respect singular and plural. The paths are
organic.category,organic.tag,organic.author; the aliases aretagsandauthors. A wrong name such ascategories,organic.categories,tagorauthordoes not raise an error: it renders an empty value. - Types. A string is text. A list is a sequence of values: test it with
in, read it with|firstor|join(', '), never print it directly. An object groups other paths. Numbers, booleans and dates follow. Dates are ISO 8601 UTC strings such as2026-10-18T07:30:00.000Z; format them with|date. - Absence. A value exists only when the source provides it. It depends on the post origin (article, manual post, linkless post), the social network, the post type and the metadata the scraper found on the page. A missing
organicpath renders as empty; an empty list is treated as missing.
Use ?? to replace a missing or null value, and ?: or |default() to also replace an empty string:
| Expression | Value missing | Value '' | Value 'A' |
|---|---|---|---|
{{ x ?? 'B' }} | B | (empty) | A |
{{ x ?: 'B' }} | B | B | A |
{{ x|default('B') }} | B | B | A |
After a filter, use ?: or |default(), never ??: {{ x|upper ?? 'B' }} is a syntax error, and the template is refused when you save it.
3. Top-level variables#
These variables are always defined in a Smart Message.
| Path | Type | Availability | Description | Example |
|---|---|---|---|---|
social_network | string | Always | Network of the destination account; facebook when unknown. | instagram |
post_type | string | Always | Post type; link when unknown. | photo |
empty_message | string | Always | An empty string, to return deliberately nothing. | '' |
shortlink_prefix | string | Always | The brand's text placed before a shortlink. | ➡️ |
post | object | Always | The post being created or edited (see post.*). | |
organic | object | Always | The source article (see organic.*); an empty list [] when the post has no article. | |
context | string | Always | Field being generated: message, title, description, comment, videoTitle, altText, tikTokAnchorTitle, or smart_image when Smart Image renders the template. | message |
3.1. Legacy aliases#
When the post comes from an article, Nonli also copies some article fields to the top level. These aliases are defined (possibly null) as soon as an article exists, and undefined when the post has none. Each one has a preferred full path.
| Alias | Preferred full path | Type | Content |
|---|---|---|---|
title | organic.title | string | Article title chosen by Nonli |
description | organic.description | string | Article description chosen by Nonli |
title1 | organic.html.h1 | string | First H1 heading of the page |
title2 | organic.html.h2 | string | First H2 heading of the page |
head_title | organic.head.title | string | HTML title tag, often longer SEO title |
meta_description | organic.meta.description | string | Native meta description of the page |
og_title | organic.meta.open_graph.title | string | Open Graph title |
og_description | organic.meta.open_graph.description | string | Open Graph description |
twitter_title | organic.meta.twitter.title | string | Twitter Card title |
twitter_description | organic.meta.twitter.description | string | Twitter Card description |
meta_custom_facebook, meta_custom_twitter, meta_custom_instagram, meta_custom_linkedin, meta_custom_tiktok, meta_custom_pinterest | organic.meta.custom.facebook (and so on) | string | Per-network custom metadata extracted by a site-specific rule |
meta_custom_publisher | organic.meta.custom.publisher | string | Custom publisher field extracted by a site-specific rule, for example the first body paragraph |
meta_custom_rss_title | organic.meta.custom.rss.title | string | Title from the RSS item |
meta_custom_rss_description | organic.meta.custom.rss.description | string | Description from the RSS item |
authors | organic.author | list | Article authors |
tags | organic.tag | list | Article tags |
On a real article, title and title1 often hold the same headline, head_title holds the longer SEO title and meta_description often starts with the standfirst. Any of them can be empty, so always plan a fallback.
The "Meta custom …" choices of the editor and the bot sources correspond to these meta_custom_* aliases. They are filled only on sites where Nonli configured a dedicated extraction rule. An article from a site outside your company exposes only its public fields, so organic.counter.view is missing for it.
4. [object Object],: the source article#
organic exists when the post comes from an article (bot, URL pasted in the editor, SDK, RSS). A manual post or a linkless post has no article: organic is then an empty list and every organic.* path is empty.
| Path | Type | Availability | Description | Example |
|---|---|---|---|---|
organic.url | string | Every article | Article URL | https://www.example.com/politics/budget |
organic.canonical_url | string | When declared | Canonical URL | https://www.example.com/politics/budget |
organic.type | string | Every article | Content type detected | article |
organic.language | string | When detected | Language code | fr |
organic.title | string | Most articles | Title chosen by Nonli | Budget 2027: what changes |
organic.description | string | When found | Description chosen by Nonli | The government presents its budget. |
organic.html.h1 / organic.html.h2 | string | When present | First H1 / H2 heading | Key measures |
organic.head.title | string | When present | HTML title tag | Budget 2027 - Example News |
organic.meta.description | string | When present | Native meta description | The government presents its budget. |
organic.meta.open_graph.title / .description | string | When present | Open Graph title / description | Budget 2027 |
organic.meta.open_graph.article.section | string | When present | Open Graph article:section | Politics |
organic.meta.twitter.title / .description | string | When present | Twitter Card title / description | Budget 2027 |
organic.meta.custom.* | string | Site-specific rule | Custom fields listed in the alias table | |
organic.category | list | When found | Normalized categories, including JSON-LD articleSection | ['politics', 'economy'] |
organic.tag | list | When found | Article tags | ['budget', 'taxes'] |
organic.author | list | When found | Article authors | ['Camille Martin'] |
organic.image | list | When found | Image URLs found on the page | ['https://…/budget.jpg'] |
organic.image_alt | string | When found | Alternative text of the main image | The National Assembly chamber |
organic.published_at | date | When found | Publication date (UTC) | 2026-10-18T07:30:00.000Z |
organic.modified_at | date | When found | Last modification date (UTC) | 2026-10-18T09:00:00.000Z |
organic.counter.view | number | See counters | Article views measured by Nonli | 1520 |
4.1. Categories and ,[object Object]#
Nonli stores the JSON-LD articleSection value, and the other category signals of the page, in organic.category. It is a list, not a text, and there is no categories variable. Values are normalized: lowercase, accents removed, punctuation turned into spaces. For example Politique & Société, Économie and Sports > Rugby become politique societe, economie and sports rugby. Always compare with values actually seen on your own articles.
1{%- set categories = organic.category ?? [] -%}2{%- if 'politique' in categories -%}3Politics4{%- endif -%}First category, or nothing: {{ organic.category|default([])|first|default('') }}
All categories: {{ organic.category|default([])|join(', ') }}
The raw Open Graph section, not normalized, stays available as {{ organic.meta.open_graph.article.section ?? '' }}.
5. [object Object],: the post#
post describes the post being created or edited. Fields are filled progressively: a field that is being generated is still empty while its own template renders.
| Path | Type | Availability | Description | Example |
|---|---|---|---|---|
post.post_type | string | Always | Post type | link |
post.message | string | When written | Current message | Three ways to save energy. |
post.title | string | When written | Current title | Budget 2027 |
post.description | string | When written | Current description | What changes for households. |
post.author | string | When known | Author of the post content (one text, not a list) | Camille Martin |
post.tag | list | When known | Post tags | ['budget'] |
post.language | string | When known | Language code | fr |
post.target_url | string | Link posts | Destination URL | https://www.example.com/politics/budget |
post.short_id | string | Once shortened | Shortlink identifier | aB3xY |
post.social_account.type | string | Always | Destination network | linkedin |
post.social_account.name | string | Always | Account name | Example News |
post.social_account.slug | string | Always | Account slug | example-news |
post.social_account.external_id | string | Always | Account identifier on the network | 1000000001 |
post.source.type | string | When known | Origin of the post (bot, manual, and so on) | bot |
post.source.bot_title | string | Bot posts | Name of the bot that created the post | Politics bot |
post.source.organic_url | string | Article posts | Source article URL | https://www.example.com/politics/budget |
post.breaking_news.active | boolean | Always | Breaking-news mode | false |
post.schedule.start_date | date | Scheduled posts | Planned publication date (UTC) | 2026-10-19T06:00:00.000Z |
post.published_at | date | After publication | Publication date (UTC) | 2026-10-19T06:00:04.000Z |
post.comment.message | string | When written | First comment text | Read more: [SHORTLINK] |
post.counter.* | number | See counters | Post performance | 42 |
Internal identifiers and technical fields (user, company and brand IDs, account email, webhook URL, statuses, error traces, raw counters) are deliberately excluded: they are not a stable public interface and must not appear in published text. post.user_id is only documented for URL parameters.
6. Counters and percentiles#
A counter is loaded only when the template names it explicitly, for example post.counter.click. It is missing when the metric does not exist yet, which is always the case for a post that has not been published. Always write a fallback: {{ post.counter.click ?? 0 }}.
| Path | Type | Description |
|---|---|---|
post.counter.click | number | Clicks |
post.counter.engagement | number | Engagement |
post.counter.reach | number | Reach |
post.counter.videoViews | number | Video views |
post.counter.ctr | number | Click-through rate |
post.counter.overperforming | number | Overperformance score |
post.counter.monetization | number | Content monetization earnings |
organic.counter.view | number | Article views (only when the post has an article) |
A percentile variable compares a post with the posts published by the same account over a period, breaking news excluded. Its name is percentile + percentile (1 or 2 digits) + metric + duration (1 or 2 digits) + unit:
- metrics:
Click,Engagement,Reach,VideoViews,Ctr,Overperforming,Monetization; - units:
hhour,dday,wweek,mmonth,yyear.
percentile50Click1d is the median of clicks over 1 day; percentile90Engagement7d the 90th engagement percentile over 7 days. The value is null without data and is cached for 30 minutes.
1{%- if (post.counter.engagement ?? 0) > (percentile50Engagement7d ?? 0) -%}TOP{%- else -%}NORMAL{%- endif -%}7. Functions#
| Function | Returns | Description |
|---|---|---|
hasLink() | boolean | true when the post carries a link. Check it before writing [SHORTLINK]. |
hasFirstCommentShortlink() | boolean | true when the first comment already contains [SHORTLINK]. |
canPublishPostWithoutSpam() | boolean | true when the account's anti-spam rules allow another link post now. |
canPublishCommentWithoutSpam() | boolean | Same check for a comment containing a link. |
shouldAddComment(metric) | boolean | Applies the configured comment sampling rule; the optional metric names the counter used. The result can vary. |
aiGenerated(source, min, target, max, ending, justCrop) | string | Summarizes source, or crops it on a word boundary when justCrop is true. |
gpt(prompt, model), claude(…), gemini(…), mistral(…) | string | Generate text with the brand's provider credential. See AI message generation. |
airtable(…) | string | Reads a value from a connected Airtable base. It returns text only. |
aiGenerated() resolves only top-level names given as text. Pass 'description' or the value itself (organic.title), never the text 'organic.title', which is returned literally. A name that does not exist is also returned literally: on a post without an article, aiGenerated('description', …) returns the word description. Guard it with a condition:
1{%- if organic -%}2{{- aiGenerated('description', null, null, 40, '…', true) -}}3{%- endif -%}8. Filters#
Standard Twig filters and operators (default, first, join, length, trim, replace, date, map, in, ??, ?:, ~) are documented in the Twig documentation. Nonli adds or replaces the following filters. All results below come from the Nonli renderer.
| Filter | Effect | Example | Result |
|---|---|---|---|
upper | Uppercase, accents kept | 'élan VITAL'|upper | ÉLAN VITAL |
lower | Lowercase | 'élan VITAL'|lower | élan vital |
capitalize | First letter uppercase, rest unchanged | 'élan VITAL'|capitalize | Élan VITAL |
slugify | URL slug | 'Budget 2027 : ménages'|slugify | budget-2027-menages |
camel | Words joined, each capitalized | 'budget des ménages'|camel | BudgetDesMénages |
clean | Collapses and trims spaces, and removes the spaces around a hyphen | ' Paris - Lyon '|clean | Paris-Lyon |
ucPhrase | Title case, short words kept lowercase | 'le budget des ménages'|ucPhrase | Le Budget des Ménages |
encode | URL-encodes but keeps reserved characters such as &, =, /, ?, # | 'a b&c'|encode | a+b&c |
extractMatch(regex) | First capture group, or null | 'Price: 10 €'|extractMatch('/([[:digit:]]+)/u') | 10 |
pregReplace(regex, replacement) | Regular-expression replacement | 'Budget 2027'|pregReplace('/[[:digit:]]+/', 'N') | Budget N |
cleanText | Typographic cleanup: trims, adds French non-breaking spaces | 'Lire aussi : le budget. '|cleanText | Lire aussi : le budget. (non-breaking space before the colon) |
Rules that avoid render errors:
extractMatchandpregReplacefail when the value isnull. Add|default('')before them:{{ meta_custom_publisher|default('')|extractMatch('/^(.+?[.!?…])(?=[[:space:]]|$)/u') }}.encodedoes not protect&,=or#: do not use it to build a parameter from a value that may contain them.- Prefer POSIX classes such as
[[:digit:]],[[:space:]]and[[:alpha:]]to backslash sequences: they survive copy and paste. Copy templates from a code block, never from a rendered chat or email line, which may drop backslashes.
9. Tested recipes#
Every recipe below was rendered with the Nonli Twig engine on complete, partial and empty article fixtures.
Headline (H1 or title) followed by the meta description, with a shortlink only where it is needed. On a Facebook link post it returns the headline and the description; on a Facebook photo it adds ➡️ [SHORTLINK]; on Instagram it never adds a link; without an article it returns an empty text.
1{%- set titre = title1 ?: title -%}2{%- set desc = meta_description ?: description -%}3{{- titre -}}4{%- if desc -%}{{- '\n\n' ~ desc -}}{%- endif -%}5{%- if post_type != 'link' and social_network not in ['instagram', 'tiktok', 'pinterest'] and not hasFirstCommentShortlink() and hasLink() -%}6{{- '\n\n' ~ shortlink_prefix -}}[SHORTLINK]7{%- endif -%}First sentence of the description, then the description, then the title.
1{%- set source = meta_description ?: description ?: '' -%}2{{- source|extractMatch('/^(.+?[.!?…])(?=[[:space:]]|$)/u') ?: source ?: title -}}First sentence of the custom publisher field, falling back to the image alt text, then the title.
1{{- meta_custom_publisher|default('')|extractMatch('/^(.+?[.!?…])(?=[[:space:]]|$)/u') ?: organic.image_alt|default('') ?: title -}}Text by network and post type.
1{%- if social_network == 'instagram' -%}2{{- title -}}3{%- elseif post_type == 'link' -%}4{{- description ?: title -}}5{%- else -%}6{{- title -}}7{%- endif -%}Smart Image color by category. Returns #E50032 for politics, #16A34A for sport and #6F819D otherwise.
1{%- set categories = organic.category ?? [] -%}2{%- if 'politique' in categories -%}#E500323{%- elseif 'sport' in categories -%}#16A34A4{%- else -%}#6F819D5{%- endif -%}Tracking values from the destination account.
1utm_source={{ post.social_account.type|default('social') }}&utm_campaign={{ post.social_account.slug|default('')|encode }}Publication date in the newsroom's time zone. 2026-10-18T07:30:00.000Z becomes 18/10/2026 09:30. Without a publication date, date would print the current date, so the condition leaves the text empty instead.
1{%- if organic.published_at|default(null) -%}2{{- organic.published_at|date('d/m/Y H:i', 'Europe/Paris') -}}3{%- endif -%}Hashtags from tags. ['budget', 'impots'] becomes #Budget #Impots.
1{{ tags|default([])|map(t => '#' ~ t|camel)|join(' ') }}Keep short words and numbers with the next word (for Smart Image titles, which never break a line on a non-breaking space). La finale se jouera à guichets fermés keeps La, se and à attached to the following word, and 16 septembre or 3 000 euros stay together. The change applies to newly generated images only.
1{%- set titre = organic.title ?? title -%}2{{- titre|pregReplace("/(?<=^|[[:space:]])([[:alpha:]]{1,2}|[[:digit:]]+)[[:space:]]+/u", "$1\xC2\xA0") -}}Twig turns \xC2\xA0 into a non-breaking space; $1 keeps the short word or number that was matched.
10. Automatic and on-click smart fields#
Each brand has two settings: Automatic configuration of smart fields and Smart field configuration at the click of the button. The editor button uses the on-click configuration, and falls back to the automatic one only when no on-click configuration exists. A field set to the public Empty template is cleared when you click the button, because the editor writes the generated value back into the field.
Bots do not use these brand settings. Each bot has its own sources, such as Message's source and Description's source, which point to a built-in value (Title, Description, and so on) or to a Smart Message template.
11. Troubleshooting#
11.1. My bot consults its source but creates no post#
An active bot can find the article and still create no post when its Smart Message renders an empty message. This happens, for example, when the template reads an optional field such as meta_custom_publisher and the article does not have it. Nonli then filters the candidate before any publication: a message that is empty, or five characters or fewer once the shortlink is removed, is discarded, and so is a link post with an empty title. The empty-message check does not apply to bots set to No message or to Pinterest; the empty-title check applies to every bot.
This is different from a post that was created and then failed on the network: such a post is visible in Posts with an error status. See Why did my bot not publish this article? to locate the stage first.
-
Compare. Identify the exact bot. Compare a few messages it generated successfully before with the metadata of the matching articles, and check what changed in its configuration. A template name or the current page HTML alone does not prove what used to work. Render the current template on a representative eligible article and check whether the variable is missing or present but empty.
-
Fix. Prefer restoring the previous message source when it gave the intended wording, or add a fallback suited to the variable type:
twig1{{- meta_custom_publisher ?: title -}}If the article title matches the intended wording, open the bot, set Message's source to Title, then click Save. This changes the generated text.
-
Scope. Changing the bot's source affects only the future posts of that bot. Editing a shared Smart Message template affects every bot and field that uses it. Existing posts are never rewritten.
-
Check. Wait for the next normal consultation and look at articles that are still eligible. Older articles are not caught up automatically, and a manual post does not prove that the bot recovered.
11.2. Short marker, standfirst and first sentence are different values#
A short editorial marker (sometimes called a kicker or surtitle), the full standfirst and the first sentence of a text are three different things. Nonli has no dedicated variable for the short marker: a site-specific rule may store it in a meta_custom_* field, and some articles legitimately have none. Do not replace a missing marker with the whole standfirst. Show the marker only when it exists:
1{%- set marker = meta_custom_instagram|default('')|trim -%}2{%- if marker -%}{{- marker ~ ' | ' -}}{%- endif -%}3{{- title -}}With a marker the result is EXCLUSIVE | Budget 2027: what changes; without one, only the title. Here meta_custom_instagram is only an example: use the field your site rule actually fills.
meta_description (native meta description), meta_custom_publisher (custom field) and description (description chosen by Nonli) are separate inputs. A template that reads one is not affected when another one is corrected. Updating a collected field does not change the selected bot source, the Smart Image text or posts already generated.
11.3. Apostrophes break my template#
In a single-quoted Twig string, an apostrophe ends the string. This template is refused when you save it, with an error such as "Invalid template syntax at line 1":
1{{ gpt('Summarize the article's main point') }}Escape the apostrophe, use a typographic apostrophe, or use double quotes:
1{{ gpt('Summarize the article\'s main point') }}2{{ gpt('Summarize the article’s main point') }}3{{ gpt("Summarize the article's main point") }}The reported line number is the line of the template that contains the error.
11.4. A variable stays empty#
- Check the exact spelling:
organic.category, notcategories;tags, nottag. - Check that the post has an article:
organicis empty for manual and linkless posts, and the aliases are then undefined. - Print a list with
|join(', ')or test it within. - Compare categories with their normalized form (
politique societe, notPolitique & Société). - Check counters with
?? 0: they are missing before publication.
11.5. Can a template add Instagram collaborators?#
No. Templates fill text fields only, and airtable() returns text: it can write an @mention, not create a collaboration. Add collaborators in the post editor with Collaborators, or with the API field collaborator[] (slug, type) of POST or PUT /v2.1/posts/bulk. Meta accepts up to 3 collaborators; see How to do an Instagram collaboration with Nonli.