Which Twig variables are available in Nonli?

Also available in:Français

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 context equals '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.title or post.social_account.type rather 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 are tags and authors. A wrong name such as categories, organic.categories, tag or author does 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 |first or |join(', '), never print it directly. An object groups other paths. Numbers, booleans and dates follow. Dates are ISO 8601 UTC strings such as 2026-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 organic path 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:

ExpressionValue missingValue ''Value 'A'
{{ x ?? 'B' }}B(empty)A
{{ x ?: 'B' }}BBA
{{ x|default('B') }}BBA

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.

PathTypeAvailabilityDescriptionExample
social_networkstringAlwaysNetwork of the destination account; facebook when unknown.instagram
post_typestringAlwaysPost type; link when unknown.photo
empty_messagestringAlwaysAn empty string, to return deliberately nothing.''
shortlink_prefixstringAlwaysThe brand's text placed before a shortlink.➡️
postobjectAlwaysThe post being created or edited (see post.*).
organicobjectAlwaysThe source article (see organic.*); an empty list [] when the post has no article.
contextstringAlwaysField 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.

AliasPreferred full pathTypeContent
titleorganic.titlestringArticle title chosen by Nonli
descriptionorganic.descriptionstringArticle description chosen by Nonli
title1organic.html.h1stringFirst H1 heading of the page
title2organic.html.h2stringFirst H2 heading of the page
head_titleorganic.head.titlestringHTML title tag, often longer SEO title
meta_descriptionorganic.meta.descriptionstringNative meta description of the page
og_titleorganic.meta.open_graph.titlestringOpen Graph title
og_descriptionorganic.meta.open_graph.descriptionstringOpen Graph description
twitter_titleorganic.meta.twitter.titlestringTwitter Card title
twitter_descriptionorganic.meta.twitter.descriptionstringTwitter Card description
meta_custom_facebook, meta_custom_twitter, meta_custom_instagram, meta_custom_linkedin, meta_custom_tiktok, meta_custom_pinterestorganic.meta.custom.facebook (and so on)stringPer-network custom metadata extracted by a site-specific rule
meta_custom_publisherorganic.meta.custom.publisherstringCustom publisher field extracted by a site-specific rule, for example the first body paragraph
meta_custom_rss_titleorganic.meta.custom.rss.titlestringTitle from the RSS item
meta_custom_rss_descriptionorganic.meta.custom.rss.descriptionstringDescription from the RSS item
authorsorganic.authorlistArticle authors
tagsorganic.taglistArticle 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.

PathTypeAvailabilityDescriptionExample
organic.urlstringEvery articleArticle URLhttps://www.example.com/politics/budget
organic.canonical_urlstringWhen declaredCanonical URLhttps://www.example.com/politics/budget
organic.typestringEvery articleContent type detectedarticle
organic.languagestringWhen detectedLanguage codefr
organic.titlestringMost articlesTitle chosen by NonliBudget 2027: what changes
organic.descriptionstringWhen foundDescription chosen by NonliThe government presents its budget.
organic.html.h1 / organic.html.h2stringWhen presentFirst H1 / H2 headingKey measures
organic.head.titlestringWhen presentHTML title tagBudget 2027 - Example News
organic.meta.descriptionstringWhen presentNative meta descriptionThe government presents its budget.
organic.meta.open_graph.title / .descriptionstringWhen presentOpen Graph title / descriptionBudget 2027
organic.meta.open_graph.article.sectionstringWhen presentOpen Graph article:sectionPolitics
organic.meta.twitter.title / .descriptionstringWhen presentTwitter Card title / descriptionBudget 2027
organic.meta.custom.*stringSite-specific ruleCustom fields listed in the alias table
organic.categorylistWhen foundNormalized categories, including JSON-LD articleSection['politics', 'economy']
organic.taglistWhen foundArticle tags['budget', 'taxes']
organic.authorlistWhen foundArticle authors['Camille Martin']
organic.imagelistWhen foundImage URLs found on the page['https://…/budget.jpg']
organic.image_altstringWhen foundAlternative text of the main imageThe National Assembly chamber
organic.published_atdateWhen foundPublication date (UTC)2026-10-18T07:30:00.000Z
organic.modified_atdateWhen foundLast modification date (UTC)2026-10-18T09:00:00.000Z
organic.counter.viewnumberSee countersArticle views measured by Nonli1520

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.

twig
1{%- set categories = organic.category ?? [] -%}
2{%- if 'politique' in categories -%}
3Politics
4{%- 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.

PathTypeAvailabilityDescriptionExample
post.post_typestringAlwaysPost typelink
post.messagestringWhen writtenCurrent messageThree ways to save energy.
post.titlestringWhen writtenCurrent titleBudget 2027
post.descriptionstringWhen writtenCurrent descriptionWhat changes for households.
post.authorstringWhen knownAuthor of the post content (one text, not a list)Camille Martin
post.taglistWhen knownPost tags['budget']
post.languagestringWhen knownLanguage codefr
post.target_urlstringLink postsDestination URLhttps://www.example.com/politics/budget
post.short_idstringOnce shortenedShortlink identifieraB3xY
post.social_account.typestringAlwaysDestination networklinkedin
post.social_account.namestringAlwaysAccount nameExample News
post.social_account.slugstringAlwaysAccount slugexample-news
post.social_account.external_idstringAlwaysAccount identifier on the network1000000001
post.source.typestringWhen knownOrigin of the post (bot, manual, and so on)bot
post.source.bot_titlestringBot postsName of the bot that created the postPolitics bot
post.source.organic_urlstringArticle postsSource article URLhttps://www.example.com/politics/budget
post.breaking_news.activebooleanAlwaysBreaking-news modefalse
post.schedule.start_datedateScheduled postsPlanned publication date (UTC)2026-10-19T06:00:00.000Z
post.published_atdateAfter publicationPublication date (UTC)2026-10-19T06:00:04.000Z
post.comment.messagestringWhen writtenFirst comment textRead more: [SHORTLINK]
post.counter.*numberSee countersPost performance42

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 }}.

PathTypeDescription
post.counter.clicknumberClicks
post.counter.engagementnumberEngagement
post.counter.reachnumberReach
post.counter.videoViewsnumberVideo views
post.counter.ctrnumberClick-through rate
post.counter.overperformingnumberOverperformance score
post.counter.monetizationnumberContent monetization earnings
organic.counter.viewnumberArticle 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: h hour, d day, w week, m month, y year.

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.

twig
1{%- if (post.counter.engagement ?? 0) > (percentile50Engagement7d ?? 0) -%}TOP{%- else -%}NORMAL{%- endif -%}

7. Functions#

FunctionReturnsDescription
hasLink()booleantrue when the post carries a link. Check it before writing [SHORTLINK].
hasFirstCommentShortlink()booleantrue when the first comment already contains [SHORTLINK].
canPublishPostWithoutSpam()booleantrue when the account's anti-spam rules allow another link post now.
canPublishCommentWithoutSpam()booleanSame check for a comment containing a link.
shouldAddComment(metric)booleanApplies the configured comment sampling rule; the optional metric names the counter used. The result can vary.
aiGenerated(source, min, target, max, ending, justCrop)stringSummarizes source, or crops it on a word boundary when justCrop is true.
gpt(prompt, model), claude(…), gemini(…), mistral(…)stringGenerate text with the brand's provider credential. See AI message generation.
airtable(…)stringReads 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:

twig
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.

FilterEffectExampleResult
upperUppercase, accents kept'élan VITAL'|upperÉLAN VITAL
lowerLowercase'élan VITAL'|lowerélan vital
capitalizeFirst letter uppercase, rest unchanged'élan VITAL'|capitalizeÉlan VITAL
slugifyURL slug'Budget 2027 : ménages'|slugifybudget-2027-menages
camelWords joined, each capitalized'budget des ménages'|camelBudgetDesMénages
cleanCollapses and trims spaces, and removes the spaces around a hyphen' Paris - Lyon '|cleanParis-Lyon
ucPhraseTitle case, short words kept lowercase'le budget des ménages'|ucPhraseLe Budget des Ménages
encodeURL-encodes but keeps reserved characters such as &, =, /, ?, #'a b&c'|encodea+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
cleanTextTypographic cleanup: trims, adds French non-breaking spaces'Lire aussi : le budget. '|cleanTextLire aussi : le budget. (non-breaking space before the colon)

Rules that avoid render errors:

  • extractMatch and pregReplace fail when the value is null. Add |default('') before them: {{ meta_custom_publisher|default('')|extractMatch('/^(.+?[.!?…])(?=[[:space:]]|$)/u') }}.
  • encode does 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.

twig
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.

twig
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.

twig
1{{- meta_custom_publisher|default('')|extractMatch('/^(.+?[.!?…])(?=[[:space:]]|$)/u') ?: organic.image_alt|default('') ?: title -}}

Text by network and post type.

twig
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.

twig
1{%- set categories = organic.category ?? [] -%}
2{%- if 'politique' in categories -%}#E50032
3{%- elseif 'sport' in categories -%}#16A34A
4{%- else -%}#6F819D
5{%- endif -%}

Tracking values from the destination account.

twig
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.

twig
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.

twig
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.

twig
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.

  1. 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.

  2. Fix. Prefer restoring the previous message source when it gave the intended wording, or add a fallback suited to the variable type:

    twig
    1{{- 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.

  3. 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.

  4. 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:

twig
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":

twig
1{{ gpt('Summarize the article's main point') }}

Escape the apostrophe, use a typographic apostrophe, or use double quotes:

twig
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, not categories; tags, not tag.
  • Check that the post has an article: organic is empty for manual and linkless posts, and the aliases are then undefined.
  • Print a list with |join(', ') or test it with in.
  • Compare categories with their normalized form (politique societe, not Politique & 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.

Was this article helpful?

Book a personalized demo with our team.