Expressions and Pipes
Expressions inject dynamic data into your templates. They use double-curly syntax and support pipes for transformations.
Variable interpolation
Use {{variable}} to insert a value. Dot-path notation accesses nested fields.
Hello {{user.first_name}},
Your order **#{{order.id}}** totals ${{order.total}}.Variables are resolved from the data object passed at compile time (via --data, --sample, or the SDK’s data parameter).
Auto-escaping
All {{expressions}} are auto-escaped for the target channel:
- Email: HTML entities (
<becomes<) - SMS: No escaping needed (plain text)
- Slack: mrkdwn special characters escaped
- Push: No escaping needed (plain text)
- WhatsApp: WhatsApp formatting characters escaped
- Teams: Adaptive Card text escaping
Raw output
Use triple-curly braces {{{expression}}} to output a value without escaping. This is useful when your data contains pre-formatted HTML or channel-native markup.
{{{order.custom_html}}}The linter emits a warning (template/raw-expression) for every raw expression, since unescaped user input is a security risk.
Pipes
Pipes transform values inline. Chain them with |.
{{name | capitalize}}
{{name | uppercase | truncate 20}}Available pipes
String pipes
| Pipe | Description | Example |
|---|---|---|
capitalize |
Capitalize first letter | {{name | capitalize}} — "hello" → "Hello" |
uppercase |
Convert to uppercase | {{name | uppercase}} — "hello" → "HELLO" |
lowercase |
Convert to lowercase | {{name | lowercase}} — "HELLO" → "hello" |
titlecase |
Convert to Title Case | {{name | titlecase}} — "hello world" → "Hello World" |
truncate N |
Truncate to N chars with ”…” | {{desc | truncate 50}} |
trim |
Strip whitespace from both ends | {{name | trim}} — " hi " → "hi" |
trim_start |
Strip leading whitespace | {{name | trim_start}} |
trim_end |
Strip trailing whitespace | {{name | trim_end}} |
replace OLD NEW |
Search and replace | {{text | replace "old" "new"}} |
append SUFFIX |
Append a string | {{name | append "@example.com"}} |
prepend PREFIX |
Prepend a string | {{path | prepend "https://"}} |
default VALUE |
Fallback if null | {{nickname | default "Friend"}} |
pluralize S P |
Singular/plural by count | {{n | pluralize "item" "items"}} |
md5 |
MD5 hash (useful for Gravatar) | {{email | md5}} |
split SEP |
Split string into array | {{csv | split ","}} |
Using | default on a nullable field suppresses the template/nullable-access linter warning. The fallback can be a quoted string or a variable path: {{nickname | default user.first_name}}.
Array pipes
| Pipe | Description | Example |
|---|---|---|
count |
Number of items | {{items | count}} — 3 |
join SEP |
Join with separator | {{tags | join ", "}} — "a, b, c" |
unique [KEY] |
Remove duplicates | {{cities | unique}} or {{users | unique "email"}} |
at INDEX |
Item at index | {{emails | at 0}} |
first |
First element | {{items | first}} |
last |
Last element | {{items | last}} |
reverse |
Reverse order | {{items | reverse}} |
sort [KEY] |
Sort ascending | {{items | sort}} or {{users | sort "name"}} |
slice START [LEN] |
Extract a slice | {{items | slice 0 3}} — first 3 items |
slice also works on strings: {{text | slice 0 5}} extracts the first 5 characters.
Math pipes
| Pipe | Description | Example |
|---|---|---|
add N |
Add | {{price | add 5}} — 9 → 14 |
subtract N |
Subtract | {{total | subtract 10}} — 50 → 40 |
multiply N |
Multiply | {{qty | multiply 2}} — 5 → 10 |
divide N |
Divide | {{cents | divide 100}} — 1500 → 15 |
round |
Round to nearest int | {{score | round}} — 12.5 → 13 |
mod N |
Remainder | {{index | mod 3}} — 14 → 2 |
abs |
Absolute value | {{change | abs}} — -10 → 10 |
ceil |
Round up | {{price | ceil}} — 4.1 → 5 |
floor |
Round down | {{price | floor}} — 4.9 → 4 |
Math pipes coerce numeric strings — "42" | add 8 produces 50. Division and modulo by zero return the original value.
Formatting pipes
| Pipe | Description | Example |
|---|---|---|
percent |
Format as percentage | {{ratio | percent}} — 0.85 → "85%" |
number [DECIMALS] |
Decimal places + thousand separators | {{amount | number 2}} — 1234.5 → "1,234.50" |
currency [CODE] |
Format as currency | {{total | currency "USD"}} — 49.99 → "$49.99" |
currency supports 30+ ISO 4217 codes: USD, EUR, GBP, JPY, INR, CNY, KRW, BRL, CAD, AUD, SGD, CHF, and more. Zero-decimal currencies (JPY, KRW) are handled automatically.
Serialization pipes
| Pipe | Description | Example |
|---|---|---|
json |
Serialize to JSON string | {{user | json}} — {"name":"Alice"} |
from_json |
Parse JSON string to object | {{json_str | from_json}} |
Date pipes
| Pipe | Description | Example |
|---|---|---|
date [FMT] [TZ] |
Format date with optional timezone | {{ts | date "%B %d, %Y"}} |
timezone ZONE |
Convert to IANA timezone | {{ts | timezone "America/New_York"}} |
relative |
Relative time from now | {{created_at | relative}} → “2 hours ago” |
date accepts both Unicode-style tokens (MMMM d, yyyy) and strftime specifiers (%B %-d, %Y). Parses ISO 8601, RFC 3339, bare dates (2024-12-01), and Unix timestamps. Returns empty string for unparseable input. Default format: %Y-%m-%d.
Unicode tokens: yyyy (year), MM/M (month number), MMMM (month name), MMM (short month), dd/d (day), HH/H (24h hour), hh/h (12h hour), mm/m (minute), ss/s (second), a (AM/PM), EEEE (weekday), EEE (short weekday).
{{order_date | date "MMMM d, yyyy"}}
"2024-03-15" -> "March 15, 2024"
{{order_date | date "%B %d, %Y"}}
"2024-03-15T10:30:00Z" -> "March 15, 2024"
{{ts | date "%I:%M %p" "America/New_York"}}
"2024-03-15T10:30:00Z" -> "06:30 AM"
timezone converts a datetime to a different IANA timezone, returning RFC 3339 output. Chain with date for formatting: {{ts | timezone "Asia/Tokyo" | date "%H:%M"}}.
relative converts a datetime to a human-readable relative time. Returns “just now” for < 60 seconds, “N minutes ago”, “N hours ago”, “N days ago”, “N months ago”, or “N years ago”. Future dates return “in N minutes”, etc.
Variable pipe arguments
Pipe arguments can reference data variables. Bare unquoted words are resolved as variable paths; quoted strings and numbers are literals.
{{item.cost | multiply item.quantity | currency "USD"}} Variable arg
{{name | append " Jr."}} Literal arg (quoted)
{{price | multiply 2}} Literal arg (number)
This lets you express cross-field calculations entirely in the template, without pre-computing values in your data payload. Variable arg resolution follows the same dot-path rules as regular expressions.
Pipe chaining
Pipes can be chained left to right. Each pipe receives the output of the previous one.
{{name | capitalize | truncate 20}}
Unknown pipes
At runtime, unknown pipe names pass through unchanged (forward-compatible).
However, the linter and strict-mode validator flag them as errors
(template/unknown-pipe, severity Error) with a Levenshtein-based suggestion
if a close match exists — so {{ name | uppercse }} is caught before it
ships. In strict mode (on by default), the send is aborted.
Pipe argument validation
Every pipe has a declared argument shape — arity bounds and per-position
types. The linter (template/invalid-pipe-arg, severity Error) and the
strict-mode validator enforce this, catching typos and wrong-type arguments
that would otherwise silently coerce to 0 or "" at runtime:
| Pipe | Arity | Arg types |
|---|---|---|
capitalize, uppercase, lowercase, titlecase, trim, trim_start, trim_end, count, first, last, reverse, abs, ceil, floor, percent, json, from_json, relative, md5 |
0 | — |
truncate, at, add, subtract, multiply, divide, mod |
1 | Number |
append, prepend, split, currency, timezone |
1 | String |
round, number |
0-1 | Number (optional) |
join, sort, unique |
0-1 | String (optional) |
replace, pluralize |
2 | String, String |
slice |
1-2 | Number, Number |
date |
0-2 | format, optional timezone |
default |
1 | any |
Examples caught by the linter/validator:
{{ x | truncate "abc" }}— string literal where number expected{{ x | truncate kdsjds }}— bare unknown word not in schema{{ x | replace "only-one" }}— arity too low{{ x | uppercase "extra" }}— arity too high
Ternary expressions
Use {{condition ? true_value : false_value}} for inline conditionals:
Status: {{order.shipped ? "Shipped" : "Processing"}}
Badge: {{user.tier == "pro" ? "Pro Member" : "Free Plan"}}Ternary conditions support the same operators as :::if: truthiness checks, ==, !=, >, <, >=, <=, and logical operators &&, ||, !.
{{user.verified && user.tier == "pro" ? "Verified Pro" : "Standard"}}
{{!order.cancelled ? "Active" : "Cancelled"}}Pipe precedence: Pipes apply to the entire ternary result, not individual branches:
{{is_vip ? "VIP" : "Regular" | uppercase}}This produces "VIP" or "REGULAR" – the uppercase pipe transforms whichever branch is selected.
Style references ({.name})
Block attributes support a shorthand {.name} syntax to apply named styles from styles: in brand.yaml (or a frontmatter brand: override):
# Welcome aboard
{.headline}
Fine print below.
{.muted}Multiple styles combine: {.headline .muted}. Later styles win on conflicts. Inline attrs override styles:
# Custom heading
{.headline size="4xl"}The {.name} syntax is parsed as a special _style attribute. The linter emits template/unknown-style if the style name is not defined in config or frontmatter. See Project Configuration for defining styles.
Comments
Use {# ... #} to add comments that are stripped from all channel output. Works inline and multiline.
Hello {# greeting for logged-in users #} {{user.name}}
{# TODO: add tracking pixel after launch #}Everything between {# and #} is removed before parsing — nothing reaches the compiled output.
Expressions in frontmatter
Frontmatter fields that support expressions use the same syntax:
---
subject: "Order #{{order.id}} has shipped!"
preheader: "Hey {{user.first_name}}, your order is on its way"
---Expressions in native JSON mode
In .json template files, expressions use Handlebars (a JavaScript templating language) syntax. Pipes become Handlebars helpers:
{
"text": "Hello {{capitalize user.first_name}}, you have {{count items}} items"
}All pipes are available as Handlebars helpers with the same names.