Chapter 5
The reason cross-channel templates matter isn't elegance. It's that the alternative is writing the same message four times and keeping four copies in sync forever. One document, many renderings, and an agent that can produce it from a sentence.

Last updated: September 2026
The reason cross-channel templates matter isn't elegance. It's that the alternative is writing the same message four times and keeping four copies in sync forever. One document, many renderings, and an agent that can produce it from a sentence.
Courier describes messages in Elemental, a JSON format built around typed blocks. You write the content once and wrap it per channel:
{"version": "2022-01-01","elements": [{"type": "channel","channel": "email","elements": [{ "type": "meta", "title": "Your order shipped" },{ "type": "text", "content": "Your order is on the way. Track it: {{tracking_url}}" },{ "type": "action", "content": "Track order", "href": "{{tracking_url}}" }]},{"type": "channel","channel": "sms","elements": [{ "type": "text", "content": "Order shipped. Track: {{tracking_url}}" }]},{"type": "channel","channel": "push","elements": [{ "type": "meta", "title": "Order shipped" },{ "type": "text", "content": "Tap to track your delivery." }]}]}
Same event, three renderings, one object. The email gets a button, the SMS keeps the link inline, the push gets a title and a tap target.
The block types you'll see most are text, action, meta, image, list, and divider. There are thirteen in total, but you can build most things with those six.
You don't write that JSON. You describe the message:
Build a shipping confirmation template. Email, SMS, and push. Email gets the order number, a tracking link as a button, and the delivery estimate. SMS is one line with the tracking link, under 160 characters. Push is a short title and a tap-to-track line. Data fields are
order_number,tracking_url, anddelivery_estimate.
The agent produces the Elemental, creates the template in your workspace as a draft, and gives you the template ID. Then you iterate in plain language: make the subject shorter, add the estimate to SMS, drop the image on push.
Ask to see the JSON at least once. You won't edit it by hand, but knowing what the agent produced is how you catch a missing channel block.
Four things an agent gets wrong unless told, in rough order of how much they'll cost you.
A channel with no channel block renders nothing, silently. The docs put it plainly: "A channel with no channel element renders no content, and this produces no error, so name every channel you send on." The send succeeds. The log looks fine. Nobody gets anything. If you add a channel to a routing config and forget to add it to the template, this is what happens.
SMS character budgets are smaller than they look. A standard message is 160 characters. Add one emoji or one curly quote and the encoding changes, the budget drops to 70, and your one message quietly becomes two billed segments. Tell the agent the limit explicitly. The SMS segment counter shows the real count as you type.
Email needs authentication you can't fake. SPF, DKIM, and DMARC on a proper sending subdomain. And avoid noreply@, which hurts deliverability and gives people nowhere to go when something's wrong.
Push titles truncate. Different on iOS and Android, and truncated in the notification shade rather than in your preview.
Elemental handles branching inside the template, so you don't need four near-identical templates for four segments.
There are two forms of if:. A Handlebars string works:
{ "type": "text", "content": "Thanks for being a Pro member.", "if": "data.plan === 'pro'" }
The structured form is better for anything an agent generates, because it's checkable:
{"type": "text","content": "You qualify for the enterprise plan.","if": [{"logical_operator": "and","conditions": [{ "property": "data.account_type", "operator": "equals", "value": "enterprise" },{ "property": "profile.email_verified", "operator": "equals", "value": "true" }]}]}
Conditions read from data.*, profile.*, and refs.*. Operators include equals, not_equals, greater_than, contains, is_empty, and their obvious siblings.
Two more worth knowing. loop: iterates an array, so an order confirmation can list line items with {{$.item.name}} and {{$.index}}. And ref: names an element so a later one can test whether it rendered.
One rule that trips agents up: stored templates require channel wrapping, while inline sends accept blocks at the top level. Same format, different rule depending on where it lives.
Templates hold content. Brands hold the look: logo, colors, fonts, header and footer. A template references a brand, which means rebranding doesn't mean touching templates.
Set up our brand. Logo is at example.com/logo.png, primary color is #4F46E5, footer says "© 2026 Example Inc."
If you're building a B2B product where each customer sees their own branding, you can attach a brand per tenant instead of per template. Decide which model you want early, because switching later means re-linking everything.
GET /messages/{message_id}/output returns what Courier actually handed the provider, broken out per channel: the resolved subject, the real HTML, the plain text part. Not a preview. Not the agent's description. The output.
courier messages content --message-id <MESSAGE_ID>
The loop that works: publish, send a test to yourself, fetch the output, read it. That catches merge variables that didn't resolve, brand styling that didn't apply, and the empty-channel problem from earlier, all before a real recipient sees any of it.
If your workspace has draft keys, you can go one better and render the unpublished draft against a real payload, so you catch problems before publishing at all.
Courier translates templates with AI directly in Design Studio, and it's a genuinely better answer than having a model translate at send time.
Open the template, click the globe icon, add a locale, and check Translate with AI. Courier translates every translatable field, including email subjects, body text, and button labels.
Then review it. The review screen shows default on the left and your target locale on the right, one row per field, all editable. Variables survive translation, but read the fields containing them, because copy has to flow naturally around a {{first_name}} that might be short in English and long in German.
The part that makes this maintainable: when you change the source copy, Courier flags the affected translations as outdated. You re-translate the changed fields rather than re-doing the locale.
At send time, the locale resolves in this order: message.to.locale, then a top-level profile.locale, then the stored profile locale, then your default content.
{"message": {"to": { "email": "user@example.com", "locale": "es" },"template": "TEMPLATE_ID","data": { "name": "Alex" }}}
Managing locales from code. Translations are API objects too, so an agent can handle them. GET /notifications/{id}/content returns translatable elements with their ids and checksums, and PUT /notifications/{id}/locales/{localeId} pushes a locale. Three traps worth knowing before you point an agent at this:
id fails the entire request with a 400 and writes nothing, so the valid overrides in the same array don't apply either.{{data.name}} breaks the render.PUT /locales/{localeId}, which merges and validates. Avoid PUT /elements/{elementId}, which is a full replace that wipes existing translations and doesn't validate its body. Misspell locales as locale and you get a 200 while the translation silently never renders.Why this rather than an AI node. An AI node would re-translate on every run, unreviewed, with no memory of the last version and no way to know the source changed. AI Translation translates once, gets reviewed, stores the result, and tells you when it's stale. Reviewed and cached beats regenerated every time.
Agents are good at structure and bad at taste. A common pattern is to let the agent build the template, then open it in Design Studio to fix the spacing and tune the copy, then hand it back for the next bulk change.
One warning that matters: a push overwrites the draft, and the draft is where Design Studio edits live. If someone has been editing in the UI, diff before you push or you'll throw their work away without either of you noticing. Chapter 8 covers how to make that a habit.
No. Describe the message and the agent produces the JSON. Reading it occasionally is worth it, mostly so you'd recognize a missing channel block, but you won't be authoring it.
Design Studio previews each channel side by side. From an agent, ask for the rendered output instead: it sends a test and fetches what Courier actually produced, which catches unresolved variables that a preview can hide.
Yes. Anything built by API or MCP is a normal template in your workspace. Open it, change it, save it. Be aware that a later push from code overwrites the draft, so diff first.
Almost always an emoji or a curly quote. Either one switches the message to a different encoding, which drops the per-segment budget from 160 characters to 70. The text looks the same length and bills as two messages.
Add a locale in Design Studio and check Translate with AI, then review the side-by-side output before publishing. Courier flags translations as outdated when you change the source copy, so re-translating is per-field rather than starting over. There's also a localization API if you'd rather manage locales from code.
Previous chapter
Prompting an Agent to Build Notifications
Most bad output from an agent isn't a model problem. It's a missing-information problem. You asked for a welcome email and didn't say which channels are live, who the recipient is, or what data you have, so the agent picked something plausible.
Next chapter
Designing Journeys with AI, and the AI Node
A journey is a multi-step flow that Courier runs on its own: send, wait, check something, branch, send again. The state lives on Courier's side, which is why a seven-day delay doesn't require a cron job or a row in your database tracking where everyone is.
© 2026 Courier. All rights reserved.