Templates: MJML or HTML with Liquid

A template is an email's subject, body and optional plain-text body, saved as an immutable version (saving the same content again makes no new version). format: mjml (MJML 5) compiles once on save (Liquid is kept for send time); format: html is cleaned and wrapped in the brand kit (below). Every save runs the checks first: errors refuse it, warnings are saved with the version. Examples: examples/welcome/templates/.

brand: main
format: mjml
subject: "Welcome, {{ contact.first_name | default: 'there' }}"
body: |
  <mjml>
    <mj-head><mj-preview>Your account is ready</mj-preview></mj-head>
    <mj-body>
      <mj-section>
        <mj-column>
          <mj-text>Hi {{ contact.first_name | default: 'there' }},</mj-text>
          <mj-text>Thanks for joining {{ brand.name }}.</mj-text>
          <mj-button href="https://example.com/start">Get started</mj-button>
        </mj-column>
      </mj-section>
    </mj-body>
  </mjml>
textBody: null        # optional; derived from the HTML when null
utm: false            # optional; overrides the account's UTM setting for this template

Importing a design (Claude Design, HTML, MJML, a web page)

POST /v1/templates/import (MCP template_import, plainsend template import <file|url> --brand <b> --key <k>, the console's Content → Import) turns a design into an MJML template in the brand's kit:

Liquid

Liquid with strict filters (an unknown filter is an error on save).

VariableValue
contact.email, contact.first_name, contact.last_name, contact.phone, contact.country, contact.region, contact.locale, contact.idThe contact's core fields (snake_case).
contact.<property>A custom property from a source mapping's properties, or one already in your contacts' data, e.g. contact.postcode.
event.<field>The triggering event's payload, in event-triggered journeys.
brand.name, brand.key, brand.from_email, brand.from_name, brand.postal_addressThe sending brand.
unsubscribe_urlThis message's unsubscribe link (one is always added in the footer anyway).

Values are HTML-escaped in the body, not in the subject or text. Two rules are checked on every save (ADR-0005):

Conditions and loops (MJML)

Liquid control flow in MJML goes through two tags; never hand-write <mj-raw>{% if %} pairs.

<ps-if when="contact.lastVisit | days_ago > 180"> <mj-section>…</mj-section> </ps-if>
<ps-for each="product in event.items" limit="10"> <ps-module ref="product-row@1" name="{{ product.name }}"/> </ps-for>

HTML templates

HTML (an import, a Claude Design export) is cleaned, then wrapped in the sending brand's kit like MJML. Removed, in one warning: script, iframe, object, embed, forms and inputs, on…= attributes, javascript: links, external stylesheets and @import, 1x1 or hidden tracking pixels. Kept: inline styles, tables, Outlook conditional comments, Liquid. It is refused when unsafe structure remains (an unclosed <script, CSS expression(), a refresh redirect).

Checks (the same list everywhere)

POST /v1/templates/<key>/lint (MCP template_lint, plainsend template lint <file>) runs every check without saving; PUT (MCP template_put, plainsend template publish <file>) runs them and returns issues; GET /v1/templates/<key>/versions/<v>/issues reads a version's. Each issue is { field, line, rule, severity, message, fix }; line is the line in that field.

RuleSeverityWhat
liquid, mjml, unknown_tag, ps_if, ps_for, unclosed_tag, block ruleserrorthe source does not parse or compile
missing_fallback, unknown_propertyerrorthe ADR-0005 lint above
unsafe_html / html_removederror / warningHTML cleaning
unsubscribe, postal_addresserrorthe footer's unsubscribe link and address are missing or hidden as sent
gmail_clipwarning over 90 KB, error over 102 KBGmail clips the email (and its footer)
img_altwarningan image without alt text
broken_linkwarninga link answers 4xx/5xx or not at all (each URL once; Liquid links skipped)
outlook_csswarningflex, grid or position, which Outlook ignores
dark_mode_logo, dark_mode_textwarninga transparent logo without logo.darkUrl; pure black text
subject_length, preview_textwarningover 60 characters; no <mj-preview> (or hidden preheader in HTML)

Added at send time (you cannot remove them)

Check before sending

Brand kits: one template, any brand's look

Each brand can have a brand kit (PUT /v1/brandkits/<brand>, MCP brandkit_put). Templates hold content only; the renderer wraps an MJML template in its sending brand's kit, so the same template renders in any brand's kit (template_preview with brand shows it in another brand's kit; a journey sending as another brand uses that kit).

colors: { primary: "#0e5e6f", accent: "#f2a541", text: "#1d2a30", background: "#f6f4ef" }   # dark-mode colours are generated
fonts:
  heading: { family: Fraunces, url: "https://fonts.googleapis.com/css2?family=Fraunces:wght@600&display=swap" }
  body: { family: Inter }                 # fallback chosen for you (Arial, Helvetica, sans-serif); or set `fallback`
logo: { url: "https://example.com/logo.png", darkUrl: "https://example.com/logo-white.png", alt: "Harbour Physio" }
button: { radius: 6, background: accent }   # primary | accent | a hex colour; text colour picked for contrast
layout: { width: 600, header: "<mj-section>…</mj-section>", footer: "<ps-module ref=\"social@2\"/>" }
footer: { text: "You're a patient of {{ brand.name }}.", preferencesUrl: "https://example.com/preferences" }

Shared blocks

A block (PUT /v1/blocks/<key>, MCP block_put) is an MJML fragment (sections) with declared fields:

name: Book a visit button
fields: [{ key: label, default: "Book a visit" }, { key: url, type: url, required: true }]
mjml: <mj-section><mj-column><mj-button href="{{ field.url }}">{{ field.label }}</mj-button></mj-column></mj-section>

Use it in a template as <ps-module ref="cta@2" url="https://example.com/book"/>. Leave the version off (ref="cta") and publishing pins the newest one. A new block version never changes a template: GET /v1/blocks and GET /v1/templates/<key> (blockUpdates) list templates with an update available; name the new version to take it.