Chapter 5

Designing Templates with AI

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.

how to build product notifications with AI

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.

How does one template render to every channel?

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.

Prompting a template into existence

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, and delivery_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.

What breaks when you ignore the channel

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.

Conditional content and per-channel overrides

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.

Brands, and when to use one per tenant

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.

How do I check what actually rendered?

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.

Translating a template with AI

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:

  • One bad element id fails the entire request with a 400 and writes nothing, so the valid overrides in the same array don't apply either.
  • Variable expressions must stay byte-identical to the default content. An agent "tidying" {{data.name}} breaks the render.
  • Use 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.

Handing off to Design Studio and back

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.

Frequently asked questions

Do I have to write Elemental by hand?

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.

How do I preview a message before sending?

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.

Can I edit an AI-generated template in Design Studio?

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.

Why did my SMS cost two segments?

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.

How do I translate a notification template into other languages?

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.