This page is for anyone writing Leed Markdown from outside the CMS — an MCP client, an import script, a generator. Three contracts decide whether your first write does what you meant: how blocks are addressed, what the page type allows, and what the parser cannot keep. The fourth section is the other direction: the plain-markdown file a reader’s own AI client is served from your published site.
Blocks are addressed by data-id
Every top-level block in a page body carries a data-id. The schema assigns it; you never do. It is stable across edits, it is what an anchored operation targets, and inventing one is the fastest way to have a whole batch of edits rejected.
Read a body and you will see them on the blocks that have them:
## Installing the CLI {data-id="h-install"}
Run the installer and follow the prompts. {data-id="p-install-1"}New content you send does not need one — ids are assigned on import.
The read-plan-apply loop
Editing an existing page is a three-step loop, and it is not optional: the anchors you edit against have to come from a read you performed moments earlier.
get_page_bodyreturns the body as Leed Markdown with every addressable block’s anchor intact, plus ablocksarray of{ dataId, hash }— one entry per block you may address. This is the planning input. It reflects the clean accepted-state body — a pending suggested deletion is not shown — and because reads go through the live collaborative document rather than a cached copy, the anchors are current at the moment you read them.update_page_bodyapplies an ordered list of operations against those anchors. Everyreplaceanddeletemust carry that block’shashasbaseHash, copied verbatim from the read; an insert may carry one too. If somebody changed that block since you read it, the edit is refused with a 409 rather than applied over their work.- If any anchor no longer exists because the page changed under you, the whole op-set is rejected — not partially applied. Re-read and re-plan.
| Op | What it does | Requires an anchor? |
|---|---|---|
insertBefore | Inserts new Leed Markdown blocks immediately before the anchored block | Yes, plus the markdown to insert |
insertAfter | Inserts new blocks immediately after the anchored block | Yes, plus the markdown to insert |
replace | Swaps the anchored block for new markdown | Yes, plus the replacement markdown |
delete | Removes the anchored block | Yes — and nothing else |
A real payload. Each anchor can be named by only one op in a call — a second op on the same data-id rejects the whole set — so a replace that also adds a block after itself carries both blocks in its own markdown:
{
"pageId": "c29c7ee8-3d88-4357-a15e-3975ed0a048b",
"mode": "direct",
"ops": [
{ "type": "replace", "anchorId": "p-install-1", "baseHash": "v4:1f0c9e2a7d3b4c58",
"markdown": "Run `leed install` and follow the prompts.\n\n:::tip Verify the install\nRun `leed --version`.\n:::" }
]
}Whole-body writes
set_page_body sets a page’s entire body from markdown. It is allowed while the body is still empty, and on a page that has never been published it also accepts overwrite: true to replace a non-empty body wholesale. A page that has been published is refused outright, and so is an overwrite of a page carrying unresolved comments. Past that point the only re-edit path is update_page_body.
That has a real consequence if you are writing at scale. Once a page is live, each correction is a set of anchored operations planned from a fresh read, not another fill — so get each body right before it is first published.
Two tools create pages, and they answer different questions:
| Tool | Use it when | What you get back |
|---|---|---|
create_page_from_markdown | You have the finished body now | A draft page with its content already set, in one call |
reserve_page | Other pages need to link to this one before its body exists | A reserved page with an empty body and a pageId |
reserve_page is what makes a cross-linked set possible, because a pageid: link needs its target’s id to exist first. The id is always generated on the server — you cannot propose one — so building a linked set is genuinely two passes: reserve every page, harvest the returned ids, then author the bodies and the menu from that map.
Both create tools accept a slug, and an explicit one is used verbatim — which is what an import preserving existing URLs needs. If the resulting path is already taken the call returns a 409 naming the page that holds it. Omit slug and it is derived from the title, with a suffix on a collision (terms becomes terms-2), so read page.slug back rather than assuming it.
Direct edits and suggestions
update_page_body takes a mode. Over MCP it defaults to direct: the operations change the body immediately and cannot be undone. With mode: "suggest" they arrive instead as tracked additions and deletions attributed to the agent that made them, exactly as a human collaborator’s suggestions would, and a person accepts or rejects them in the editor. Pass mode explicitly on every call. In either mode, baseHash is what stops a concurrent human edit from being clobbered.
Suggest mode has one hard limit: a block with no text cannot be tracked, so an empty paragraph or heading, or a paragraph holding only an icon, is rejected as the target of a suggestion. direct mode replaces and deletes those freely. An image, iframe or form block is tracked through an attribute instead, so it can be edited in both modes.
The page type decides which features are allowed
A page type carries nine editorFormattingOptions. Markdown that uses a feature the page type disables is rejected server-side, on every write path — the same validation the toolbar and the AI writing assistant both consult.
| Key | Default | Gates | Label in the rejection message |
|---|---|---|---|
codeBlock | true | A fenced code block whose language is not mermaid | code blocks |
diagrams | false | A fenced code block whose language is mermaid | diagrams (mermaid) |
mathBlock | false | Math fences | math blocks |
alert | false | ::: alert containers | alerts |
icons | false | {% icon %} | icons |
tabGroup | false | ===tabs-container | tab groups |
iframe | false | {% iframe %} | iframes |
collapsibleBlock | false | +++ collapsible blocks | collapsible blocks |
table | false | Tables | tables |
Headings, paragraphs, lists, task lists, blockquotes, images, links, every inline mark, horizontal rules, hard breaks and the video, audio and form embeds are never gated by these flags.
The crucial scoping rule: only posts-type page types gate anything. On a documentation or an api page type the check returns immediately and every feature is allowed, the toolbar shows every control, and the Settings screen does not even render the toggles. If you are writing into a documentation set, this table describes a mechanism that is switched off for you.
When a gate does fire, the error names the features verbatim:
This page type does not allow: alerts, tables. Allowed formatting features: code blocks.That message is worth parsing rather than retrying blindly — it tells you both what to remove and what you may use instead. Changing the toggles is a page-type setting, covered on What Each Page Type Lets You Format.
Raw HTML is kept as text
This is the single hardest constraint on writing into Leed. A body containing HTML is accepted, but the HTML does not survive as markup.
The published site renders HTML written in a template. A page body cannot hold it, because the document schema has no node for it — so the parser keeps each tag as literal text, and the published page shows <div class="note"> exactly as you typed it. The write’s validation lists every line this changed, so it is never silent:
<div class="note">Read this</div> becomes \<div class="note">Read this\</div>.Two kinds of HTML are not kept as text. <br> becomes a hard line break, and a line holding only <br> tags becomes a paragraph of line breaks. An HTML comment, <!-- … -->, is stripped on import, as a Handlebars comment is, and validation reports the removal as dropped_content. Inside a code span or a fence, HTML and comments are code and are kept as written.
Most hand-written HTML has a Leed Markdown equivalent: alerts, collapsible sections, tabs, and attributes on ordinary blocks between them replace most wrapper markup. What is left usually belongs in a layout rather than in a page body. The full comparison is on Fidelity and Unsupported Syntax.
Escaping when markdown travels as JSON
Two rules, both of which only bite inside a payload and are therefore missed until something renders wrong.
Braces come back escaped. The serializer escapes a literal { or } in body text, because Leed uses braces for attributes, so text you sent unescaped is read back as \{ and \}. It also escapes the first character of a line of text that would otherwise open a block — :::, @tab, ===tabs-container, a bare ===, +++ or ++> — so prose cannot turn into an alert, a tab or a collapsible on the next write. Leed adds no other escaping: colons and at-signs come back as you sent them, and text inside an inline code span is never escaped. Ordinary Markdown escaping still applies, so a literal *, _ or [ in prose also comes back with a backslash. All of this is expected and does not affect the rendered page. Do not “fix” it on read and send the fixed version back — you will accumulate backslashes.
LaTeX backslashes double inside JSON. A math fence containing \frac{a}{b} has to be written \\frac{a}{b} in a JSON string, like any other backslash. The most common symptom of getting this wrong is a math block that renders as literal text.
There is no third rule about link fragments. A pageid: link may carry a #fragment — [label](pageid:<id>#section-slug) resolves to the target page’s heading — so if your client carries a guard that strips one, remove it. The mechanics are on Links and Internal Links.
The plain-markdown export
Every published page is also served as a plain-markdown file. This is the artifact most external AI clients actually consume, and it is a different thing from the Leed Markdown you write: Leed’s own extensions are stripped, and structure is kept.
flowchart LR
Y["Page body (Y.Doc)"] --> S["serializeBodyToMarkdown"]
S --> LM["Leed Markdown"]
LM --> C["cleanContent<br/>embeds → HTML comments"]
C --> P["leedMarkdownToPlainMarkdown<br/>strip attributes and fences' attrs"]
P --> B["buildFlattenedMarkdown<br/>prepend # title"]
B --> O["<page url>index.md"]
B -. "api pages only" .-> A["append ## OpenAPI yaml block"]
A --> O
O --> D["Docs MCP page content"]
What gets stripped
| Leed Markdown | In the exported .md |
|---|---|
{data-id="…"} and any other attribute block | Removed; a line that is only an attribute block is deleted |
| Attributes on a code or mermaid fence | Removed; the fence and its language survive |
 |  — the size is stripped from the alt text |
[text](/x){target="_blank"} | [text](/x) — the attribute suffix is stripped |
| A Handlebars comment | Removed entirely |
:::note … ::: | Kept, fence and type word intact |
+++ Title … +++ | Kept, fence and title intact |
===tabs-container and @tab Label | Kept, attributes stripped from both |
| Headings, lists, tables, blockquotes, code fences | Kept unchanged, contents included |
Structure survives; decoration does not. A reader’s client sees your alerts, your tabs and your collapsibles as recognizable fences, which is why an AI answering from these files can still tell a warning from a paragraph.
Embeds become comments
An embed has no plain-markdown equivalent, so each one is replaced by an HTML comment that says what was there:
| Embed | In the exported .md |
|---|---|
{% form … %} | <!-- embedded Leed form exists in the rendered page --> |
{% youtube … %} | <!-- embedded youtube video exists in the rendered page --> |
{% cloudflare … %} | <!-- embedded cloudflare video exists in the rendered page --> |
{% audio … %} | <!-- embedded audio exists in the rendered page --> |
{% iframe … %} | <!-- embedded iframe exists in the rendered page --> |
{% icon … %} | <!-- embedded icon exists in the rendered page --> |
A client reading the file therefore knows a video was there and that it cannot show it, rather than silently seeing a gap.
Where it is served
The file lives at the page’s own URL plus index.md. This page is at /docs/leed-markdown/markdown-for-ai-mcp-and-import/index.md; the attributes page is at /docs/leed-markdown/attributes/index.md. The file opens with the page title as an # H1 — which is why a body should not start with one of its own — followed by the stripped body.
An API-type page gets one addition: its OpenAPI operation is appended as a fenced yaml block under a ## OpenAPI heading. Eleventy consumes that operation to render the human page and strips it from the HTML output, so the exported markdown is the only place the structured contract — method, path, parameters, security, responses — survives into hosted markdown. That is deliberate: it gives a client the exact spec alongside the prose describing it.
Who reads it
The Docs MCP hydrates a page’s content from exactly these files. When a reader’s assistant fetches a page from your documentation set, what it receives is the index.md above and nothing else — so anything you need an AI to know has to survive the strip. Docs MCP: AI Access for Your Readers explains how readers connect to it, and Docs MCP Tool Reference documents the tools that serve it.
The syntax guide an AI client can ask for
An MCP client does not have to guess at this format. describe_schema with target: "leed_markdown" returns the complete Leed Markdown syntax guide as text, generated from the same canonical specification these documentation pages are written against. The same guide is embedded in the description of every markdown field on every page tool, so a client that reads tool schemas has it before it makes its first call.
describe_schema also answers page, page_type, form and menu with JSON Schema, which is the fastest way to learn what a create or update call will accept.
The guide and this documentation are kept in step on purpose. If you find them disagreeing, that is a bug in one of them and worth reporting rather than working around.
Importing existing content
An import is a bulk version of everything above. Stated as requirements rather than as a tool walkthrough, content is importable when:
- It contains no raw HTML. HTML no longer fails the write, but it is stored as visible text, so convert it first. This is the requirement that costs most imports the most work.
- It sets no
id. A user-written{#id}or{id="…"}is dropped from every element and reported; a heading’s id is always its generated slug, so write in-page links against that. - It invents no
data-ids. Leave them out; they are assigned on import, so a page created over MCP or by an import can be edited with anchored operations straight away. - Internal links use
pageid:, with a#fragmentwhere a specific section is genuinely the destination. That means reserving every page first and rewriting links from the harvested id map, because apageid:pointing at a page that has not been created resolves to nothing. - Images carry an asset URL and a
data-assetidobtained beforehand. There is no asset-upload MCP tool; upload files withleed asset upload, which returns ready-to-paste Leed Markdown, so images are in the asset library and their ids in hand before any body is written. - The leading H1 is removed. The layout renders the page title; a body starting with
# Titlepublishes a duplicate. - Each body holds at most 50,000 table cells. A larger one is refused with
markdown.too_many_table_cells; split the table across pages, or publish the data as a downloadable file. Table alignment, spans and cell classes import and survive as written — see Tables. - The page type’s formatting options are honored — which, on a
documentationorapipage type, means everything is allowed.
Read each write’s validation rather than assuming the body went in as sent. Whatever the CMS changes, it changes once — the stored body is a fixed point that a later save leaves alone — and anything it loses is reported as dropped, so an import script can stop on a dropped_content or dropped_attributes finding instead of discovering the loss on the published page. A finding is a real loss: a numbered list whose first item carries {start="14"}, in a quote or not, and a <br> between two words lose nothing and are not reported.
The end-to-end sequence — which call to make when, and what each one returns — is Authoring Pages Over MCP, with full parameter tables in MCP Tools: Pages and Content. The reserve-first pattern is the same one human documentation authors follow, described from their side on Linking Between Docs Pages. And once a body is published, the JSON frontmatter that accompanies it in the site repository is a separate contract with its own required fields: Front Matter Reference.