Architecture

One contract, layered adapters

The IR at the bottom, the compiler above it, and thin adapters on top. Dependencies point one way only. The IR knows nothing about markup, the compiler knows nothing about React, MCP or HTTP, and the adapters contain no email logic.

The layers

module boundaries
                 @repo/block-ir         ← the contract: schema, grammar, ids, tree ops,
                       ▲                  repair, migrate, hydrate   (deps: zod + nanoid only)
                       │
               @repo/email-compiler     ← IR → markup: walker, emitters, dialects, lint
                       ▲
       ┌───────────────┼────────────────┬──────────────┐
  @repo/editor   heddlemail-mcp   heddlemail CLI   apps/web API
  (UI only)      (adapter only)   (adapter only)   (adapter only)
LayerOwnsStatus
@repo/block-irSchema, the definition registry, grammar, identity, tree utilities, hydrate, legacy migrationShipped: IR v2, with repair() and the AI schema. v1 documents are upgraded by migrate().
@repo/email-compilerThe generic walker, per-target emitters, dialect emitters, the compatibility lintShipped: HTML, MJML and React Email targets; Handlebars, Jinja2, Liquid and Thymeleaf dialects; lint; annotate.
@repo/editorCanvas overlay, store, inspector, layers: UI onlyShipped in Phase E (gate pending in CI), hosted at /editor in apps/web.
heddlemail-mcpThe developer surface: the contract exposed as MCP toolsShipped (Phase D): the five local tools. Remote tools are planned, Phase G.
heddlemail CLIheddlemail check and a GitHub Action over the same functionsShipped (Phase D).
apps/web APITemplates, workspaces, sessions, the server-side MJML endpointPlanned, Phase G.
Module boundaries are a locked decision

A package imports only from the layers below it, and only through its exports map, with no deep imports. block-ir depends on zod and nanoid and nothing else. Each package is tested through its public functions, and its exported names are snapshot-tested so an interface change shows up as a visible diff. CI runs the dependency-cruiser rules, the interface snapshots, the no-type-switch scan and the adapter scan.

Where a change goes

You're changing…You touchYou never touch
A block typeits definitions-registry entry and its compiler-registry entrythe walker, the editor canvas, MCP
One output targetthat target's slot on each compiler entry, or the skeletonthe IR, the other targets
A templating dialectthat dialect's emitterblocks, targets, the walker
A lint ruleone rule file plus its fixturecompiler output
An editor control or panelthe editor, through the store's APIthe IR, the compiler
An MCP, CLI or API toolthat adapter, calling an existing package functioneverything else
The IR shapeblock-ir + version + migrate + docs, in one PRn/a: this change is meant to ripple

Producers and consumers

The editor is one producer of IR, not the product. A coding agent calling in over MCP, and the HTML, MJML and Figma importers planned for Phase H, are peers of it. They all produce the same validated document, and they all go through the same boundary.

the boundary
// Every producer that is not the editor lands here first.
const doc = hydrate(modelOutput)
//  1. drop nulls                          (strict modes emit null for "absent")
//  2. parse against documentSchemaForAI   (bounded, id-less, no restricted keys)
//  3. stamp ids                            (nanoid(10), never model-generated)
//     and fill content and styles from registry[type].defaults()
//  4. re-validate against documentSchema   (canonical or it throws)

Nothing downstream has to ask whether a document is trustworthy. If it exists as an EmailDocument, it satisfied the schema.

repair(): the non-throwing path

A pure, deterministic repair() sits beside hydrate(). It stamps ids, wraps stray blocks, strips keys a type does not own, unwraps over-deep frames and clamps sizes, returning { document, valid, applied, remaining }. It never invents a leaf’s content. The AI schema is generated from the registry (frames unrolled, no recursion, restricted style keys omitted) and exported as JSON Schema for strict modes. Older documents enter only through migrate().

One tree, many outputs

A compile target is not a file full of branches. It is a slot on each registry entry, and one generic tree-walker that calls into it:

ts
compile(doc, { target: 'mjml', variables: 'jinja2' })

// per node:  compilerRegistry[block.type][target](block, ctx)
// ctx gives: renderChildren(width):  children compiled at a width
//            runs(runs):             inline text in the active dialect
//            options:                target, variables, annotate

React Email was added as a target by filling the react-email slot on each registry entry. A target never means a new file that switches on block type.

Compilers throw, they never emit an empty string

A target that has no implementation for a block type raises a clear error. The bug class that motivated this rewrite was an exporter with default: return "" at the bottom of a switch, silently dropping every container and list from the sent email while the canvas showed them fine.

Emit-first

The browser emits MJML and React Email source. It does not run mjml.compile(); that happens in Node tests today and server-side later. The heavy dependency and its Node-isms stay out of every browser bundle.

Scope separation

Local scope is the MCP server over stdio, run by one developer; it never sits in the production request path or a browser bundle. Production is the multi-tenant web service, which knows nothing about MCP: a remote tool is a client of its API with the same standing as the editor. Session state lives server-side, and the bridge holds only a session id. The MCP surface in detail →

Where styling lives

Tailwind styles the application frame (the panels, the buttons, this page). It never reaches the email. Anything a recipient can see flows through a block’s styles, in px only, because Outlook desktop ignores rem and em. The compatibility lint enforces that.