Templates
Each notification type has a template with content for each channel, in each language it sends in, written in Liquid and edited in the dashboard.
Create a template
A template belongs to one notification type, such as payment_received, and holds the content for
each channel that type uses. Create it in the dashboard under Templates, then write and turn on
each channel it should go out on. Sending a type without a template fails with a validation error
that names the type.
Each channel has three fields:
| Channel | Fields |
|---|---|
| Subject, HTML body and an optional text body | |
| In-app | Title, message and an optional link the item opens |
| Push | Title, message and an optional link the notification opens |
A template can also carry a description, and a topic that groups it with others on your preferences screen.
Write the content
Templates are Liquid. They see the payload you send, plus recipient:
Thanks, {{ recipient.name }}. We received your payment of {{ amount }}.recipient holds the fields the send gave it: id, and email, name, phone, timezone and
locale when present. A key the payload lacks renders empty instead of failing the delivery. Once
your Chaski has languages, templates also see locale, the language the channel
renders in.
Numbers, money and dates
Send values as they are, and let the template format them for the reader:
{{ count | number }} items, {{ amount | currency: 'USD' }}, paid {{ paidAt | format_date }}| Filter | Writes |
|---|---|
number | 1,500 in English, 1.500 in German. number: 2 shows exactly two decimals. |
currency: 'USD' | The amount in that currency, as the language writes money: $1,500.00, 1500,00 US$. |
format_date | The date in the long style: October 30, 2026, 30 de octubre de 2026. format_date: 'medium', 'short' adds the time, and format_date: 'none', 'short' shows the time alone; the styles are full, long, medium, short and none. |
They format in the language the content renders in, with the region the send asked for when the
content is in its language: a contact in es-PE reading Spanish content gets Peru’s format. With
no languages saved, they format in English. Dates show in the contact’s timezone, or UTC
without one, and a number, or a string of digits, counts seconds since the epoch, as Liquid’s
date filter reads it. A value they can’t read prints as it came; a filter written wrong, such as
currency without a code, fails the delivery for good, as a template Liquid can’t parse does.
In Liquid only nil and false are false, so an empty string passes {% if note %}. A branch on
an optional value tests {% if note != blank %}, which an empty string and a missing key both
fail.
The email body is HTML, so Chaski escapes every value it outputs there: a name such as Ana <b>
arrives as text, not markup, and a value you already pass through escape isn’t escaped twice. End
a tag with raw to output HTML you built yourself:
<p>Thanks, {{ recipient.name }}.</p>
{{ receipt_html | raw }}raw applies to the whole value, so escape text from people before adding markup to it:
{{ comment | escape | newline_to_br | raw }}. The subject, the text body, and in-app and push
content are plain text and come out as written, so show in-app items as text in your frontend.
An HTML entity there, such as · or ’ from content written for an HTML tool,
reaches people as typed: type the character itself. The editor points one out under its field,
and an import lists each one it finds, in its preview too, without changing the content.
A template Liquid can’t parse is the author’s to fix: the dashboard names the field when you save it, and a delivery that hits one fails at once instead of retrying.
Preview and test
The editor previews each channel as you type, rendered with sample data you keep with the template. For email, Send test delivers the saved content, rendered with the sample data, to the address you enter.
Languages
Your Chaski sends in a default language, the one every template is written in first, and in the other languages you add under Languages on the dashboard’s Channels page. Each channel of a template then keeps its content in each of them: a translation with the same three fields. Every field is the translation’s own: one it leaves empty goes out empty, never in the default language.
A send asks for a language with its own locale, or else the contact’s recipient.locale. Each
channel then picks its closest content on its own:
- the translation in that locale, such as
es-PE; - else the translation in its language,
es; - else the default content.
Only your Chaski’s languages take part, so one you take off keeps its translations unused until it comes back. A send can email in Spanish while its in-app item goes out in English, when only the email has Spanish. Every delivery records the language it went out in, next to the one the send asked for: Deliveries shows both, Templates shows each template’s languages, complete, outdated or missing, and the Overview counts the deliveries that fell back to another language. A locale that isn’t a BCP 47 tag counts as absent, so the send goes out in the default language rather than failing.
A translation remembers the default content it was written from. When that content changes, the translation shows as outdated until you save it again or mark it up to date. It keeps going out meanwhile, as written.
The default language changes only while no template has a translation: each one was written from the default content.
Keep templates in a file
Templates can also live in your repository, as a JSON file your app loads over its binding with
env.CHASKI.templates().import(), from a seed route or a deploy job. Templates are keyed by type, so
loading the same file twice changes nothing, and channels the file leaves out are never touched.
import templates from '../chaski-templates.json';
// { applied: true, changes: [{ type, change: 'create' | 'update' | 'unchanged', fields, warnings }] }
const result = await env.CHASKI.templates().import(templates);{ preview: true } reports the changes without applying them. A load overwrites the types the
file holds, edits made in the dashboard included, so bring those into the file first:
templates().export() returns every template in this format. To load the file with no Worker
running, before wrangler dev or from a deploy job, turn it into SQL with
npx chaskijs templates sql and run it with wrangler d1 execute in the Chaski project:
Local development has the script.
{
"format": "chaski.templates",
"version": 2,
"defaultLocale": "en",
"templates": [
{
"type": "payment_received",
"topic": "Billing",
"channels": [
{
"channel": "email",
"enabled": true,
"subject": "Payment of {{ amount | currency: 'USD' }} received",
"body": "<p>Thanks, {{ recipient.name }}.</p>",
"translations": {
"es": {
"subject": "Recibimos tu pago de {{ amount | currency: 'USD' }}",
"body": "<p>Gracias, {{ recipient.name }}.</p>"
}
}
}
]
}
]
}defaultLocale names the language of each channel’s own content, and translations holds the
channel in your other languages. A file with translations loads only into a Chaski whose
languages are saved with that default, and a translation the file leaves out keeps what it has.
An export writes "outdated": true on a translation written from older content, and loading it
keeps it so. A file in one language can leave both keys out. Files of "version": 1, the format
before languages, still load; one that carries translations is refused rather than loaded
without them.
Updated Oct 30, 2026
MIT License · chaski.sh