Agents

The MCP server is the developer surface

heddlemail-mcp ships to customers’ developers. Their own agent is the generator: it reads the contract, writes an EmailDocument, and uses the tools to check, repair, compile and preview it, so it always knows the rules, gets path-addressed errors, and never emits raw markup.

Status · Phase D shipped, not yet on npm

The local server, the heddlemail CLI and the GitHub Action are built and tested in CI (packages/mcp-server), including an install from the packed tarball. The package is not published yet, so npx heddlemail-mcp works only from a local tarball for now. The eval baseline was deferred (decision D11), and it is recorded before the npm release. The remote tools are Phase G.

The product is developer-first. A developer runs npx heddlemail-mcp next to their coding agent, and the agent gets a sanctioned way to do email work instead of improvising nested tables that nothing validates. The repo’s own coding agent uses the same tools.

the surface
your agent (Claude Code, Cursor, …)
      |  MCP tool calls (stdio)
      v
heddlemail-mcp                   <- adapter only, zero email logic
      |
      +- local tools  --> @repo/block-ir . @repo/email-compiler
      |                   pure, in-process, no network, no keys
      |
      +- remote tools --> apps/web API          (Phase G)
                          sessions . sandbox test send
An adapter, never an implementation

Every tool resolves to a function the packages already ship. A tool that computes its own answer is a second source of truth, and two sources of truth drift. It is the same reasoning that put per-type behaviour in the registry instead of many switches. A source scan fails the build on any block-type branch or markup string inside an adapter.

Running it

The server speaks MCP over stdio. It is one bundled file with no runtime dependencies, so the published package is a single download.

json
{
  "mcpServers": {
    "heddlemail": { "command": "npx", "args": ["-y", "heddlemail-mcp"] }
  }
}

In this repo, .mcp.json points at the local build. Run pnpm -F heddlemail-mcp build once and the repo’s coding agent gets the same tools.

Local tools

Pure, in-process, no network and no keys. Every tool has an outputSchema, returns structuredContent (plus the same JSON as text), and carries the readOnlyHint / idempotentHint annotations. A document argument may be an object or its JSON text, and may omit ids: a draft is parsed with the AI schema and then hydrated.

ToolBacking call
describe_blocksA fold over the definition registry: grammar, types, style tiers, binding rules, limits, examples
check_irdocumentSchema + grammar + lintDocument
repair_irrepair() from block-ir, returning { ok, document, applied[], issues[] }
compile_ircompile(doc, { target, variables }), byte-identical to the package output
preview_irpreviewSrcDoc(doc) (byte-identical to export), plus its size in bytes

Alongside them: the resources ir://contract, ir://schema/v2 (and ir://schema/v2/all-required for strict modes that require every key) and ir://examples/two-cards, and design_email as a prompt template that steers the customer’s agent, not server-side generation. We do not run a model to write the email; the agent the developer already has does that, and the tools keep it honest.

One issue shape

ts
interface Issue {
  severity: 'error' | 'warning' | 'info'
  code: string
  path: string        // where in the document, e.g. root.children.0.children.2
  message: string
  hint: string
  blockId?: string
  fix?: 'repair_ir'   // present when repair_ir can fix it
}

isError is reserved for unusable input, such as a document that is not JSON at all. A document with problems is a normal result whose issues say what to fix. Lint warnings are not errors. An older-version document is migrated automatically, and the migration is reported as info. When the grammar rejects a block, schema errors inside that block are dropped as consequences, so the agent sees the cause rather than a cascade.

What crosses the boundary

An EmailDocument. Not HTML, and not a diff. Every agent-written document is parsed with the AI schema and then run through hydrate(), the same boundary as every other non-editor producer. The server has no privileged path around the schema.

a round trip
check_ir({ document })
  -> { ok: false, issues: [{ severity: 'error', code: 'grammar', path: 'root.children.0',
        message: 'cannot place "text" inside "body": "body" accepts only [section]',
        fix: 'repair_ir', … }] }

repair_ir({ document })
  -> { ok: true, document,
       applied: [{ path: 'root.children.0', code: 'wrap',
                   change: 'wrapped a stray text in a new section' }, …],
       issues: [] }

compile_ir({ document, target: 'mjml', variables: 'jinja2' })
  -> { ok: true, language: 'xml', code, issues }   // the same bytes the package emits

The CLI and the GitHub Action

The package also ships a heddlemail bin over the same core, so the checks an agent runs locally are the checks CI runs on the PR.

bash
heddlemail check welcome.email.json           # exit 1 when there are errors
heddlemail repair draft.json --out fixed.json  # exit 1 if anything stays unrepaired
heddlemail compile fixed.json --target mjml --variables liquid --out welcome.mjml
heddlemail describe                            # the describe_blocks contract
yaml
- uses: ./.github/actions/heddlemail-check
  with:
    files: emails/**/*.email.json

The eval harness

pnpm -F heddlemail-mcp eval runs a set of briefs against Claude models, each run on a fresh server over the real protocol. It scores the recorded tool calls and the final document for first-pass validity, check rounds, whether repair fixed it, compile success, lint warnings, and coverage of what the brief asked for. It compares four ways of producing a document:

  • loop: the agent uses the tools itself.
  • oneshot: non-strict decoding, then check and repair (decision D9’s default). It runs with the unrolled schema and with a recursive variant, which settles “unrolled vs recursive” with data.
  • strict: whole-document strict decoding, which records whether the API accepts the schema at all.

Live runs cost API credits. --fake runs the same pipeline with scripted drafts and no network. No baseline has been recorded yet (decision D11), so there are no published success rates.

Remote tools

session_get and session_put let rounds of work accumulate on a workspace document, so an agent can edit a session that then reopens in the editor. send_test sends to a sandbox inbox only. These forward to the apps/web API and need Phase G.

What the server deliberately does not do

There is no server-side design_email generation tool, because it duplicated the customer’s agent and added model costs. And there is no production sending: Heddlemail does not relay mail through provider keys. The output is templates in the customer’s dialect, handed to their own ESP or backend.

Local scope and production scope

Two different systems that share a contract, and keeping them apart is a locked decision.

Local scopeProduction scope
WhoOne developer, one agentMany tenants, many end users
TransportMCP over stdioOrdinary HTTP APIs
Knows about MCPIt is the MCP serverNo. A remote tool is a client of the API, with the same standing as the editor
StateA session id, and nothing elseWorkspace to document and assets, held server-side

The MCP server never sits in the production request path or a browser bundle, and @modelcontextprotocol/sdk is never bundled into apps/web or the editor. dependency-cruiser holds the server’s shipped source to block-ir, email-compiler, the MCP SDK and zod. See the roadmap for the gate.