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
@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)| Layer | Owns | Status |
|---|---|---|
@repo/block-ir | Schema, the definition registry, grammar, identity, tree utilities, hydrate, legacy migration | Shipped: IR v2, with repair() and the AI schema. v1 documents are upgraded by migrate(). |
@repo/email-compiler | The generic walker, per-target emitters, dialect emitters, the compatibility lint | Shipped: HTML, MJML and React Email targets; Handlebars, Jinja2, Liquid and Thymeleaf dialects; lint; annotate. |
@repo/editor | Canvas overlay, store, inspector, layers: UI only | Shipped in Phase E (gate pending in CI), hosted at /editor in apps/web. |
heddlemail-mcp | The developer surface: the contract exposed as MCP tools | Shipped (Phase D): the five local tools. Remote tools are planned, Phase G. |
heddlemail CLI | heddlemail check and a GitHub Action over the same functions | Shipped (Phase D). |
apps/web API | Templates, workspaces, sessions, the server-side MJML endpoint | Planned, Phase G. |
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 touch | You never touch |
|---|---|---|
| A block type | its definitions-registry entry and its compiler-registry entry | the walker, the editor canvas, MCP |
| One output target | that target's slot on each compiler entry, or the skeleton | the IR, the other targets |
| A templating dialect | that dialect's emitter | blocks, targets, the walker |
| A lint rule | one rule file plus its fixture | compiler output |
| An editor control or panel | the editor, through the store's API | the IR, the compiler |
| An MCP, CLI or API tool | that adapter, calling an existing package function | everything else |
| The IR shape | block-ir + version + migrate + docs, in one PR | n/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.
// 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.
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:
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, annotateReact 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.
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.