Skip to content
Source-available SDK

The features other editors gate. In your repo. Free to embed.

The power features, and a clean set of essentials — all included, nothing gated. Portable JSON in, MJML out, no usage tier in the way.

Source-available · FSL-1.1-MIT · Free to self-host
Agent Skill

One skill for the template and the integration

An open-source Agent Skill that gives any AI coding agent both halves of the job: writing Templatical templates from a prompt, validated against the block schema before you ever see them, and wiring @templatical/editor into your own application. It routes each request itself — you never pick a mode. No backend, no API key, nothing sent to us.

A first draft in one sentence — and the integration that ships it, in the same session.

  • Runs on the agent you already use — the model is the inference
  • One command installs it into every skills-compatible agent on your machine
  • Every generated template is schema-validated and quality-linted
  • Live mode previews and hand-edits in the real editor, then reconciles
  • Dedicated converters for the major email builders — anything else it maps onto the block schema by hand
  • Mounts the editor in your app: proposes the change first, then checks it against your running dev server
  • Debugs an integration that misbehaves against a table of verified traps
Agent Skill guide
  • From scratch
    “A product-launch email for our new Pro tier — hero, three feature callouts, and a button to the changelog.”
  • Migrate
    “Import this Unlayer export and rebuild the image-only header as real text.”
  • Refine live
    “Show it live. The CTA is too quiet — make it the accent colour and move it above the fold.”
  • Integrate
    “Mount the editor in our Next.js admin and point save and load at our own API.”
  • Diagnose
    “The editor’s dropdowns render behind the modal it’s mounted in. Why?”
Extensibility

Custom blocks with API-backed data

Register your own block types — static templates or live data fetched from your API at preview time. Built in, not bolted on.

Ship CRM-aware blocks your team drops in without engineering tickets.

  • Per-field config: text, image, color, select, repeatable arrays
  • Static template or live API fetch at preview time
  • Liquid templates with conditionals and built-in filters
  • Type-safe block factories with full TypeScript types
See the block API
const editor = await init({
  container: '#editor',
  customBlocks: [
    {
      type: 'event-details',
      name: 'Event Details',
      description: 'Date, time, location, and a map link',
      fields: [
        { type: 'text',  key: 'eventName', label: 'Event Name', required: true },
        { type: 'text',  key: 'date',      label: 'Date',       default: 'April 15, 2026' },
        { type: 'text',  key: 'location',  label: 'Location' },
        { type: 'text',  key: 'mapUrl',    label: 'Map Link (optional)' },
        { type: 'color', key: 'accent',    label: 'Accent',     default: '#7c3aed' },
      ],
      template: `
        <div style="border: 2px solid {{ accent }}; padding: 20px; border-radius: 8px;">
          <h3 style="color: {{ accent }};">{{ eventName }}</h3>
          <p>📅 {{ date }} · 📍 {{ location }}</p>
          {% if mapUrl %}
            <a href="{{ mapUrl }}">View on Map →</a>
          {% endif %}
        </div>
      `,
    },
  ],
})
Personalization

Merge tags with pluggable syntax

Handlebars, Liquid, JS template literals, or your own — with human-readable labels rendered directly on the canvas. No vendor-locked syntax.

Build a CRM-aware tag picker in an afternoon, not a sprint.

  • Built-in syntaxes plus a hook for your own
  • Human-readable labels rendered directly on the canvas
  • Inline autocomplete — type the syntax opener to surface matching tags
  • Optional sample values render in previews instead of the label
  • Optional onRequest hook to swap the picker for your CRM UI
  • Round-trip safe — JSON stores the canonical token
Merge-tag reference
const editor = await init({
  container: '#editor',
  mergeTags: {
    syntax: 'liquid',
    // An optional sample is what previews render in place of the label,
    // so a preview reads like a delivered email instead of a list of
    // field names. Set one and a Sample / Label switch appears; set
    // none and the editor behaves exactly as it did before.
    tags: [
      { label: 'First name',      value: '{{first_name}}',  sample: 'Ada' },
      { label: 'Email',           value: '{{email}}',       sample: 'ada@example.com' },
      { label: 'Plan name',       value: '{{plan_name}}',   sample: 'Pro' },
      { label: 'Order ID',        value: '{{order_id}}',    sample: 'A-4417' },
      { label: 'Order total',     value: '{{order_total}}', sample: '$128.00' },
      // No sample — this one keeps its label and its highlight, so the
      // remaining highlights double as a list of what's still missing.
      { label: 'Unsubscribe URL', value: '{{unsubscribe_url}}' },
    ],
  },
})
Targeting

Display conditions

Show or hide blocks based on recipient attributes, with live preview in the editor. Built in, not a paid add-on.

Personalize without bolting on a separate targeting service.

  • Per-block show/hide rules from recipient attributes
  • Live preview while editing
  • allowCustom: true lets editors add conditions inline
  • Wrappers are opaque strings — any syntax your ESP evaluates at send time
Conditions guide
const editor = await init({
  container: '#editor',
  displayConditions: {
    conditions: [
      {
        label: 'VIP Partners',
        before: '{% if vip_partner %}',
        after: '{% endif %}',
        group: 'Audience',
        description: 'Show only to VIP partner accounts',
      },
      {
        label: 'Early Bird',
        before: '{% if early_bird %}',
        after: '{% endif %}',
        group: 'Registration',
      },
    ],
    allowCustom: true,
  },
})
Dynamic content

Loops and conditionals inside the copy

Register your template language’s control flow — Liquid, Handlebars, whatever you already send — and authors insert it from a picker. Tags render as styled pills in the rich text and pass through to the output untouched.

Authors write conditional copy without learning your syntax.

  • Wraps a phrase mid-sentence, where display conditions wrap a whole block
  • Standalone tags and open/close pairs, grouped in the picker
  • A pair wraps the current selection — no manual closing tag
  • Also available in inputs: button text, URLs, alt text
  • Passes through to the rendered MJML unchanged
  • Or hand off to your own picker with a single onRequest hook
Logic tags reference
const editor = await init({
  container: '#editor',
  logicTags: {
    tags: [{ label: 'Else', value: '{% else %}', group: 'Conditions' }],
    pairs: [
      {
        label: 'If VIP',
        before: '{% if customer.vip %}',
        after: '{% endif %}',
        group: 'Conditions',
        description: 'Show the wrapped copy only to VIP customers',
      },
      {
        label: 'Loop items',
        before: '{% for item in order.items %}',
        after: '{% endfor %}',
        group: 'Loops',
      },
    ],
  },
})
Languages

Language versions in one template

One template keeps every language of an email: a shared block tree, plus each language’s text, property overrides such as links and images, and hidden blocks. Authors switch versions from the Languages menu, see what is missing or out of date in the language bar and on each block, and the default language’s text stands in wherever a translation is missing.

One email to maintain, however many languages it ships in.

  • localization.locales lists the languages authors can add; existing templates need no migration
  • Structure is shared: adding, moving or deleting a block changes every language
  • A translation of an older default is Out of date until an author re-translates it, keeps it or uses the default text
  • renderToMjml(content, { locale }), editor.toHtml({ locale }) and template-tools render --locale render one language, with its lang and dir
  • @templatical/quality reports localization.untranslated, localization.stale and localization.merge-tags-differ, and template-tools validate --locale runs them in CI
  • template-tools strings export and strings import exchange a language’s strings as JSON for a translation management system; XLIFF and CSV aren’t supported
  • The Beefree, Unlayer and Topol importers keep the source’s languages
  • Comments record their language; version history stays per template, and column structure can’t differ per language
  • The editor’s own UI language, init({ locale }), is separate from the template’s languages
Localized templates guide
const editor = await init({
  container: '#editor',
  // The languages authors can add from the Languages menu.
  // settings.locale stays the template's default.
  localization: {
    locales: ['en', 'de', 'fr'],
    // Masked during Translate and checked on return.
    doNotTranslate: ['Acme'],
  },
})

// Render one language version without switching the view.
const german = await editor.toMjml({ locale: 'de' })
Preview

Previews with real data, resolved by your backend

The editor recognises merge tags and logic tags — it never evaluates them. Hand it a resolvePreview callback and whatever already renders your sends renders your previews too, with branches taken and data filled in.

A preview that agrees with the delivered email by construction, not by approximation.

  • Your engine, your data, your template language — nothing to reimplement in the browser
  • Evaluates logic that sample values structurally cannot — conditional branches collapse to the one that applies
  • Resolves for the selected recipient in the test-email dialog
  • Display-only — resolved content never reaches getContent(), export, or a send
  • A resolver outage degrades to the unresolved template and says so, never a blank preview
  • Never runs while editing — the canvas always shows the tag you inserted
Preview-rendering guide
const editor = await init({
  container: '#editor',
  // Called by preview surfaces only — never while editing. The
  // test-email dialog passes the selected address as `recipient`;
  // the editor's own preview mode has none.
  resolvePreview: async ({ content, recipient }) => {
    const data = recipient
      ? await fetchSubscriber(recipient)
      : await fetchSampleSubscriber()

    // Whatever renders your sends renders your previews.
    return renderWithMyEngine(content, data)
  },
})
Branding

Theming and brand defaults

27 OKLch tokens, custom fonts, dark mode, complete theme overrides. Every surface tokenized — and the same init() call sets the defaults every new template and block starts from.

The editor looks like your product, and every new block starts on-brand.

  • 27 OKLch design tokens covering every surface
  • Light + dark theme overrides via the same theme.dark key
  • Custom fonts via --tpl-font-sans and --tpl-font-mono
  • Tailwind 4 with `tpl:` prefix — no preflight, no style leaks
  • Per-block-type defaults: button, divider, spacer, image, social
  • Template defaults: width, background, font family
Theming & defaults reference
const editor = await init({
  container: '#editor',
  uiTheme: 'auto',
  theme: {
    '--tpl-color-primary':    '#0d9488',
    '--tpl-color-accent':     '#0ea5e9',
    '--tpl-color-background': '#ffffff',
    '--tpl-radius':           '10px',
    '--tpl-font-sans':        'Inter, system-ui, sans-serif',
    dark: {
      '--tpl-color-primary':    '#22d3ee',
      '--tpl-color-accent':     '#a78bfa',
      '--tpl-color-background': '#0b1220',
    },
  },
})
Isolation

Drop into any page — host CSS can't interfere

The editor mounts inside a Shadow DOM by default. Your app's stylesheets, design system preflight, and CMS template resets stop at the boundary — they never cascade into the toolbar, sidebar, or canvas.

Embed in any framework, CMS, or legacy app — no resets, no !important wars, no surprises after a design-system bump.

  • Shadow DOM mount by default — no host CSS leaks in
  • Editor styles can't leak out either (tpl: Tailwind prefix in light-DOM mode)
  • Project your brand across the shadow boundary via --tpl-user-* CSS variables
  • Opt out with shadowDom: false for light-DOM mount when you need it
  • Multi-instance safe — each editor gets its own shadow root
Style-isolation guide
const editor = await init({
  container: '#editor',
  // Shadow DOM by default — host stylesheets stop at the boundary.
  // Your design system's preflight, *{ box-sizing }, and body font
  // can't cascade into the editor. Set to false for a light-DOM
  // mount if you need to inspect editor nodes from host scripts.
  shadowDom: true,

  // To project your brand across the shadow boundary, set
  // --tpl-user-* CSS variables on the container (or any ancestor).
  // They inherit through the shadow root.
  theme: {
    '--tpl-user-color-primary': '#0d9488',
    '--tpl-user-font-sans':     'Inter, system-ui, sans-serif',
    '--tpl-user-radius':        '10px',
  },
})
Linting

Template quality checks before send

The MIT quality linter checks accessibility, structure, links, deliverability patterns (not inbox placement), and email-client compatibility while you author. The Issues tab lists findings; errors and warnings also have canvas badges.

Catch template issues before send, with a suggestion and reference link for each finding.

  • Accessibility: alt text, contrast, heading order, and touch targets
  • Structure and links: duplicate IDs, empty sections, unsafe URLs, and staging hosts
  • Deliverability flags content patterns named by spam filters and mailbox-provider guidelines; it does not predict inbox placement
  • Caniemail-backed checks of authored pass-through HTML and CSS name affected clients from a dated snapshot
  • Every issue has a message, suggestion, and reference link; auto-fixes are available where a rule provides one
  • The Issues tab groups findings by severity; canvas badges mark errors and warnings
  • In the local editor, Check size measures compiled HTML for Gmail clipping when an HTML compiler is configured
  • Run lintTemplate headlessly or templatical validate in CI and agents; validate --html adds compiled size
Linting reference
const editor = await init({
  container: '#editor',
  // Powered by the optional peer @templatical/quality.
  // Lazy-loaded on first use
  lint: {
    accessibility: {
      // Per-rule severity overrides — 'error' | 'warning' | 'info' | 'off'.
      rules: {
        'a11y.img-missing-alt':          'error',
        'a11y.img-alt-is-filename':      'warning',
        'a11y.link-target-blank-no-rel': 'off',
      },
      thresholds: {
        minFontSize:      16,
        minTouchTargetPx: 44,
      },
    },
    links: {
      nonProductionHosts: ['*.staging.*', '*.preview.*'],
    },
    // Set any linter to false to skip it entirely — e.g. structure: false.
    // Or set disabled: true to disable all lint checks.
  },
})
Assets

Bring your own media browser

A single onRequestMedia hook lets the editor open your media browser — S3, Cloudinary, your own CMS, anything. No vendor storage, no asset egress fees, no lock-in.

Reuse the asset pipeline you already run, end-to-end.

  • One async hook returns { url, alt } — bring any backend
  • Triggered from image blocks, image fields, and the toolbar
  • Context-aware accept hint — the editor tells you what it wants
  • No upload happens through Templatical — your storage, your auth
  • Wins over a media provider when both are set — the built-in modal never opens
  • Cloud build adds a managed media browser when you opt in
Media-library reference
const editor = await init({
  container: '#editor',
  // Editor calls onRequestMedia when the user picks an image —
  // open your own asset browser (S3, Cloudinary, your CMS, etc.)
  // and resolve with { url, alt } — or null on cancel.
  async onRequestMedia({ accept } = {}) {
    const picked = await openAssetBrowser({
      accept,                 // e.g. ['images']
      endpoint: '/api/assets',
    })
    if (!picked) return null
    return { url: picked.url, alt: picked.alt }
  },
})
Headless

Build templates without the editor

Every block type has a factory function in the types package — MIT, no editor, no DOM. Compose a template in a script, seed a starter library, or generate one per customer from your own data.

Templates as data, produced by code as easily as by hand.

  • A factory per block type, each with sensible defaults
  • Factories generate the ids, so content is valid by construction
  • Runs anywhere — build script, server, queue worker, test
  • Produces the same JSON the editor reads and writes
  • MIT-licensed with no runtime dependencies
Programmatic templates guide
import {
  createDefaultTemplateContent,
  createTitleBlock,
  createParagraphBlock,
  createButtonBlock,
} from '@templatical/types'

const content = createDefaultTemplateContent()

// Each factory generates its own id, so the result is valid by construction.
content.blocks = [
  createTitleBlock({ content: '<h1>Welcome aboard</h1>' }),
  createParagraphBlock({ content: '<p>Here is what to do first.</p>' }),
  createButtonBlock({ text: 'Open your dashboard', url: 'https://example.com' }),
]

// Feed it to the editor, or straight to the renderer.
await init({ container: '#editor', content })
Connect your backend

One key per capability. The same shape. Absent until you pass one.

Each backend capability is one config key holding methods you implement. Omit a key and the feature is gone — not disabled, and its UI is never downloaded. Pass false instead of a method and the editor hides that control rather than greying it out.

Persistence

Saving and loading, against your own storage

Give the editor somewhere to save and it grows the chrome that goes with it: an inline-editable name, a save button, a status indicator, Cmd/Ctrl+S, optional autosave, and a warning before the tab closes with unsaved work.

The whole save lifecycle, with your API as the only storage.

  • Three methods are the entire integration — load, create, save
  • Debounced autosave that pauses during undo, so a redo never races a write
  • The template id is yours — a database key, a slug, a document id
  • That id is the join key: version history and comments attach to it
  • onSaved carries the trigger — manual, autosave, rename, restore or api
  • A failed save leaves editor state untouched; nothing is marked saved that wasn’t
  • Omit the key and persist from onChange yourself instead
Saving & loading reference
const editor = await init({
  container: '#editor',
  templates: {
    load: (id) =>
      fetch(`/api/templates/${id}`).then((r) => r.json()),

    create: (input) =>
      fetch('/api/templates', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(input),
      }).then((r) => r.json()),

    save: (id, patch) =>
      fetch(`/api/templates/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(patch),
      }).then((r) => r.json()),

    autoSave: true,
    onSaved: (template, { trigger }) => {
      if (trigger === 'manual') router.push(`/templates/${template.id}`)
    },
  },
})

// Opening a template is imperative — your app decides which one.
await editor.load('tpl_123')
History

Browse, preview and restore past versions

A history control in the header steps back through past states, previews one on the canvas with its own banner, and restores it behind a confirmation. Four methods against your own storage.

Undo that outlives the session, without building the UI for it.

  • Four-method provider — list, get, create, restore
  • Content on a listed version is a per-entry hint: hydrate the recent ones, make the rest a round-trip
  • No atomic restore endpoint? Compose it from get plus save
  • The confirmation before a restore discards unsaved work is the editor’s job, not yours
  • Automatic versions belong to whoever implements save — the side that knows what storage costs
  • Pass create: false and the control disappears rather than greying out
Version-history reference
const editor = await init({
  container: '#editor',
  versionHistory: {
    list: (templateId) =>
      fetch(`/api/templates/${templateId}/versions`).then((r) => r.json()),

    // A listed version can omit content as a cache hint — the editor
    // calls get() the first time it's opened, then caches the result.
    get: (templateId, versionId) =>
      fetch(`/api/templates/${templateId}/versions/${versionId}`)
        .then((r) => r.json())
        .then((v) => v.content),

    create: (templateId, content) =>
      fetch(`/api/templates/${templateId}/versions`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content }),
      }).then((r) => r.json()),

    restore: (templateId, versionId) =>
      fetch(`/api/templates/${templateId}/versions/${versionId}/restore`, {
        method: 'POST',
      }).then((r) => r.json()),
  },
})
Review

Threaded review, anchored to blocks

A review panel with threads and replies, a count badge on every commented block, and resolve/reopen. Five methods, plus a top-level user — because without an author the feature reports itself unavailable rather than writing an anonymous comment.

Stakeholder review inside the editor, on your storage and your identities.

  • Five-method provider — list, create, update, delete, setResolved
  • user.id decides what a session may edit or delete
  • setResolved takes the target state, not a toggle, so two clicks can’t invert it
  • Comments anchor to a block, or to the template as a whole
  • An optional subscribe carries a realtime transport if you have one — and everything works without it
  • Pass create: false for a read-only review pass
Comments reference
const editor = await init({
  container: '#editor',
  // A top-level key, not part of the provider — presence features will
  // want the same answer. No user, and the panel reports itself
  // unavailable rather than writing an anonymous comment.
  user: { id: currentUser.id, name: currentUser.name },
  comments: {
    list: (templateId) =>
      fetch(`/api/templates/${templateId}/comments`).then((r) => r.json()),

    create: (templateId, input) =>
      fetch(`/api/templates/${templateId}/comments`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(input),
      }).then((r) => r.json()),

    update: (templateId, commentId, patch) =>
      fetch(`/api/templates/${templateId}/comments/${commentId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(patch),
      }).then((r) => r.json()),

    delete: (templateId, commentId) =>
      fetch(`/api/templates/${templateId}/comments/${commentId}`, {
        method: 'DELETE',
      }).then(() => undefined),

    // Takes the target state, not a toggle — two clicks in flight can't
    // leave a thread inverted.
    setResolved: (templateId, commentId, resolved) =>
      fetch(`/api/templates/${templateId}/comments/${commentId}/resolve`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ resolved }),
      }).then((r) => r.json()),
  },
})
Collaboration

Real-time co-editing, over your own backend

Several people edit one template at once. The editor sends every edit to your backend as a batch of operations, your backend puts the batches in one order, and every open editor applies them in that order. Selecting a block locks it, and the header shows who else has the template open.

Live co-editing on your own realtime transport and template store.

  • join, submit and operationsAfter are the contract; acquireLock, renewLock and releaseLock add block locks
  • Your backend applies each batch with applyOperations from @templatical/types, the engine the editor runs
  • A resent batch with a known batchId answers its revision and applies nothing, so retries are safe
  • Undo and redo send inverse operations; a step that another person’s edit has overtaken is skipped with a notice
  • Other open editors name an agent’s or the AI’s batches in a notice and outline the blocks they changed
  • With collaboration on, templates.load also returns the revision, and templates.save receives only a new name
  • No realtime channel? Edits still go through submit in order, and other people’s edits appear on the next load
Collaboration reference
let sessionId = ''

const editor = await init({
  container: '#editor',
  user: { id: currentUser.id, name: currentUser.name },
  // With collaboration on, load also returns the template's revision, and
  // save receives only a new name: content arrives through submit.
  templates: myTemplatesProvider,
  collaboration: {
    // Subscribe to the template's events, answer with its revision, locks
    // and members, and call the handlers for everything that follows.
    join: (templateId, session, handlers) => {
      sessionId = session.sessionId
      return myRealtime.join(templateId, session, handlers)
    },

    // api() is your fetch helper. A refusal rejects with the HTTP status as
    // statusCode and the response's code, or it is retried as a network error.
    submit: (templateId, batch) =>
      api(`/api/templates/${templateId}/operations`, {
        method: 'POST',
        headers: { 'X-Session-Id': sessionId },
        body: JSON.stringify(batch),
      }),

    operationsAfter: (templateId, revision) =>
      api(`/api/templates/${templateId}/operations?after=${revision}`, {
        headers: { 'X-Session-Id': sessionId },
      }),

    // Optional together: without them the editor takes no locks.
    acquireLock: (templateId, blockId) =>
      api(`/api/templates/${templateId}/locks`, {
        method: 'POST',
        headers: { 'X-Session-Id': sessionId },
        body: JSON.stringify({ blockId }),
      }),
    renewLock: (templateId, blockId) =>
      api(`/api/templates/${templateId}/locks/${blockId}`, {
        method: 'PUT',
        headers: { 'X-Session-Id': sessionId },
      }),
    releaseLock: async (templateId, blockId) => {
      await fetch(`/api/templates/${templateId}/locks/${blockId}`, {
        method: 'DELETE',
        headers: { 'X-Session-Id': sessionId },
        keepalive: true,
      })
    },
  },
})

await editor.load('tpl_123')
AI

AI in the editor, on your own model

Pass an AI provider and the editor gains the AI assistant, a chat that edits the template with one undo step per turn and takes image and PDF attachments; Rewrite in the paragraph toolbar, with a Translate preset; Fix with AI on lint findings; and Write alt text for one image or in bulk. Your backend calls the model, with your keys and your data-processing terms.

The editor’s AI on the model, keys and terms you already have.

  • From @templatical/ai-kit (MIT): createHttpAiProvider talks to your backend over a documented REST and server-sent-events wire, and the server kit has the prompts, the apply_operations tool and the editing loop for any Fetch-API runtime
  • Model adapters: createAnthropicAdapter, createOpenAiAdapter (Chat Completions) and createVercelAdapter (an AI SDK model or a gateway model id, run with streamText), from @templatical/ai-kit/adapters/anthropic, /openai and /vercel. Each takes images, PDFs and image URLs; through the Vercel adapter, PDF support depends on your AI SDK provider package
  • Anthropic and OpenAI take a client you construct, so the adapter reads no environment and holds no keys: an Anthropic client may set its own baseURL, and OpenAI also takes new AzureOpenAI() or any OpenAI-compatible Chat Completions endpoint through baseURL. Each provider SDK is an optional peer: install the one for the adapter you import, and nothing else installs. Any other model plugs in through AiModelAdapter
  • With attachments on, Import from screenshot or PDF on the empty canvas opens the AI assistant with the files and a prefilled request; nothing sends until the author does
  • Optional stored history, with Undo and Redo replayed by your backend; without it, the chat lives in memory for the session
  • Conformance suites check your provider and routes; an eval set measures your model on edits, attachments, lint fixes and alt text
  • Fix with AI and Write alt text need @templatical/quality with lint on
  • In a language view, every AI feature edits that language; Rewrite’s Translate preset translates one paragraph, and whole language versions go through the translation provider
  • The template, its merge tags and the prompts go to the model you call
AI reference
import { createHttpAiProvider } from '@templatical/ai-kit/client'

const editor = await init({
  container: '#editor',
  // Every AI request names a template, so the AI button appears once one
  // is loaded.
  templates: myTemplatesProvider,

  // Your backend serves the routes with @templatical/ai-kit/server and
  // calls the model with your keys. Nothing passes through Templatical.
  ai: createHttpAiProvider({ baseUrl: '/api/ai' }),
})

await editor.load('tpl_123')
Translation

Translation through your own provider

Pass a TranslationProvider, any object with a translate method, and the language bar gains Translate. It sends a version’s missing and out-of-date fields to your backend, which can call an LLM, DeepL or a translation management system. Merge tags, logic tags and do-not-translate terms travel as numbered placeholders, and a returned field is applied only when every placeholder comes back.

Translation on the service, keys and terms you already have.

  • One method and no extra package: translate(request, signal) returns the fields it translated, and an omitted field keeps its previous value
  • Translate sends only the gaps, so current translations and manual edits survive; Translate everything again replaces them after a confirmation
  • Optional, from @templatical/ai-kit (MIT): createHttpTranslationProvider in the browser; readTranslationRequest and translateFields on the server, which run any AiModelAdapter and repair a failed field once
  • At most 50 fields and 20,000 characters per call; on content_too_large the editor halves the batch and retries
  • localization.doNotTranslate protects up to 500 terms, such as brand names, with the same exact check
  • HTML blocks, empty defaults and hidden blocks aren’t sent; translate HTML blocks by hand
  • The kit also has runTranslationConformance, which checks any provider, and createMockTranslationProvider, which answers from recordings for demos and tests
  • Subject lines aren’t part of the template: translate them with the same provider and store them in your own data
  • The fields and the language pair go to the service your backend calls
Translation reference
const editor = await init({
  container: '#editor',
  localization: { locales: ['en', 'de'], doNotTranslate: ['Acme'] },

  // Any object with translate(request, signal); no package needed. Your
  // backend calls DeepL, a translation management system or an LLM with
  // your keys. The editor masks merge tags and protected terms before the
  // call and checks every placeholder in the answer.
  translation: {
    async translate(request, signal) {
      const res = await fetch('/api/translate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(request),
        signal,
      })
      if (!res.ok) throw new Error(res.statusText)
      return res.json() // { fields: [{ id, text }] }
    },
  },
})
Reuse

Saved blocks, in your storage

Users pick a group of blocks, name it, and drop it into any other template. The editor ships the whole experience — pick session, searchable library, live preview, insert at position. You implement four methods against your own API.

A block library your users fill themselves, on your backend.

  • Four-method provider — list, create, update, delete
  • Pass false instead of a function and the editor hides the control
  • Per-entry flags lock individual entries as read-only
  • Free-text categories, derived from whatever the entries carry
  • Search and category filters run in the editor, not your API
  • Bundled browser-local provider for demos — one line, no backend
Saved-blocks reference
import { createLocalStorageSavedBlocksProvider } from '@templatical/editor'

const editor = await init({
  container: '#editor',
  // Stores entries in localStorage — no backend, good for demos.
  savedBlocks: createLocalStorageSavedBlocksProvider(),
})
Delivery

Test sends through your own infrastructure

A user mails themselves the template they are editing — and it leaves from your ESP, your domain, your reputation. The editor owns the trigger, the dialog, the preview, and the sending states. You implement one method.

A real inbox check before anything reaches a campaign, with no vendor in the path.

  • One send method is the entire integration
  • Omit the key and the feature is absent — no button, none of its code downloaded
  • The dialog previews exactly what is being sent, desktop or mobile
  • Display conditions are honoured, so the preview never shows content the recipient won’t get
  • Restrict the recipient list to reshape the field — free text, read-only, or a picker
  • Throw with a message and it shows inline; the dialog stays open to retry
Test-email reference
const editor = await init({
  container: '#editor',
  testEmail: {
    // The whole integration. A Test button appears in the header, and
    // the address the user picks is handed to you.
    send: async ({ recipient, content }) => {
      const res = await fetch('/api/test-email', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ recipient, content }),
      })

      // Your message reaches the user verbatim — write it for them.
      if (res.status === 429) throw new Error('Too many test emails — try again in a minute.')
      if (!res.ok) throw new Error('Could not send the test email.')
    },
  },
})
Media

A media library, in your own storage

The editor owns the picker — browse on image fields, video thumbnails and custom-block fields, drag-and-drop upload, crop, folders, search. You own storage. `list` is the only required method; the other nine are yours to enable or withhold, one at a time.

A media library your users browse and fill, entirely on your own storage.

  • Ten-member provider — `list` is the only one that can’t be `false`
  • Browse triggers on image fields, video thumbnails and custom-block image fields
  • Dropping a file calls `create` directly — the modal never opens, and its listing is untouched
  • Pass `false` on any mutation and the editor hides that control instead of disabling it
  • Folders come back as a flat list — the UI trees them via `parentId`
  • Bundled browser-local provider for demos — one line, no backend
  • `onRequestMedia` is a separate seam, not this store — set both and the callback wins
Media reference
import { createLocalStorageMediaProvider } from '@templatical/editor'

const editor = await init({
  container: '#editor',
  // Stores entries in localStorage — no backend, good for demos. Folders,
  // replace, import, usage, frequently-used and quota are all false.
  media: createLocalStorageMediaProvider(),
})
Output

JSON in, MJML out

Templates are portable JSON you store wherever you like. Output is MJML, rendered by a package you install — in the browser, on your server, in a queue worker. No hosted render service sits in the path.

Own the output. Send through any provider, for as long as you like.

  • MJML is an open standard with implementations in several languages
  • Render in the browser, on your server, or in a background job
  • Custom blocks resolve through a callback you supply
  • Nothing calls home — no render API, no per-render pricing
  • The renderer is MIT-licensed and installed separately
  • No `render` key needed for `toMjml()` — add one for `toHtml()`, or to move the conversion to your backend
How rendering works
const editor = await init({ container: '#editor' })

// Loads the renderer on first call — custom blocks resolve automatically.
const mjml = await editor.toMjml()

// Compile MJML to HTML with whichever MJML library you prefer.
const { html } = mjml2html(mjml)
The essentials

Everything else you expect — done right.

Drop-in mount, framework-agnostic, every locale you need. Plus the polish — dark mode, undo/redo, responsive preview.

Blocks out of the box
Twelve block types ready to drag in — title, paragraph, image, button, section, divider, spacer, social icons, menu, table, video, and raw HTML — plus any custom types you register.
Drop-in framework integration
One init() call to mount, one to unmount. First-class examples for React, Vue, Svelte, Angular, and vanilla JS.
Dark mode
First-class dark mode with auto-detect or manual toggle. Both themes are designed, not an afterthought.
Internationalization
Seven locales built in — English, German, Portuguese (BR), Spanish, Catalan, French, and Dutch — across the editor and the media library. Drop in a file for any other language.
Undo / Redo
Full history stack. Debounced to group rapid changes into sensible undo steps.
Responsive preview
Toggle desktop, tablet, and mobile viewports to see how every email renders on every device.
Painless migration

Already in another editor? Bring your templates with you.

One importer per source format — hosted editors plus raw HTML and MJML. Free, open-source, MIT, no manual rebuilding and no vendor lock-in.

  • Import saved editor documents directly, no re-export step
  • Convert raw HTML emails — Mailchimp, SendGrid, hand-coded
  • Automatic block mapping and style preservation
  • Every converter reports what needs a manual look

BeeFreeUnlayerStripoTopolChamaileonEasy Email ProHTMLMJML

See all importers
Get started

Pick your starting point.

Install the SDK

Add the package, mount with one init() call, ship. First-class examples for every major framework.

Migrate your templates

Already in a hosted editor — or sitting on a folder of HTML emails? Import them with automatic block mapping, no manual rebuild.

  • One skill for the template and the integrationAgent Skill
  • Custom blocks with API-backed dataExtensibility
  • Merge tags with pluggable syntaxPersonalization
  • Display conditionsTargeting
  • Loops and conditionals inside the copyDynamic content
  • Language versions in one templateLanguages
  • Previews with real data, resolved by your backendPreview
  • Theming and brand defaultsBranding
  • Drop into any page — host CSS can't interfereIsolation
  • Template quality checks before sendLinting
  • Bring your own media browserAssets
  • Build templates without the editorHeadless
  • Saving and loading, against your own storagePersistence
  • Browse, preview and restore past versionsHistory
  • Threaded review, anchored to blocksReview
  • Real-time co-editing, over your own backendCollaboration
  • AI in the editor, on your own modelAI
  • Translation through your own providerTranslation
  • Saved blocks, in your storageReuse
  • Test sends through your own infrastructureDelivery
  • A media library, in your own storageMedia
  • JSON in, MJML outOutput

Arrow keys to move · Enter to jump · Esc to close