Writing a page body over MCP is three tools in one order, and getting the order wrong is the single most common way an AI-authored page set fails. You reserve pages, you fill their bodies, and from then on you edit them through anchored block operations that land as tracked suggestions for a person to accept.
The order matters because a pageId cannot be chosen in advance and a body can only be filled while it is still empty. Both of those are deliberate, and both of them punish the obvious approach — write page one completely, then page two, then link them together — because by the time you want the link, the body you would put it in is no longer fillable.
If you have not connected a client yet, Connecting to the Operator MCP is the sign-in; this page assumes tools are already listed.
sequenceDiagram
autonumber
participant M as Your MCP client
participant L as Leed
participant H as A human, later
M->>L: create_page × N
L-->>M: a pageId for each — bodies empty
Note over M: Collect every id before writing anything
M->>L: fill_page_markdown — body carrying pageid cross-links
L-->>M: written
Note over M,L: The body is no longer empty
M->>L: get_page_markdown
L-->>M: markdown with a data-id anchor on every top-level block
M->>M: Plan ops against those anchors
M->>L: apply_page_markdown_ops
alt An anchor no longer exists
L-->>M: 409 stale anchor — nothing applied
M->>L: get_page_markdown (re-read and re-plan)
else All anchors resolve
L-->>M: Tracked suggestions written
H->>H: Accepts or rejects each one in the page editor
end
Before you author anything
Two preconditions are worth checking once, before a long import rather than after it.
The page type must exist and be unlocked. list_page_types returns every page type in the workspace with its pageTypeId, slug, name and type (documentation, api, posts, or absent for a custom type). get_page_type_schema returns one of them in full, including its requiredFields — which decides whether a page can ever be published without a summary, keywords or a feature image. A locked page type refuses new pages outright, and an api-type page type refuses CMS edits entirely because its pages are generated from a spec.
On posts page types, formatting is gated. A page type carries editorFormattingOptions, nine switches that decide which rich blocks its pages may use. Both fill_page_markdown and apply_page_markdown_ops validate the markdown you send against them and reject the whole call with a 400 naming the offending features:
This page type does not allow: alerts, tables. Allowed formatting features: code blocks.The defaults are narrow. Only codeBlock is on; alert, table, tabGroup, collapsibleBlock, diagrams, mathBlock, icons and iframe are all off unless the page type turns them on.
Which switch controls which block, and how to change them, is on What Each Page Type Lets You Format and Configuring a Page Type.
Step 1 — reserve
create_page { pageTypeId, title } creates a page in DRAFT status with an empty body and returns { page: { pageId, title } }. That is all it does. Reserve every page in the set before you author any body, so that a body written on the first pass can already link to a page that does not have content yet.
The id is minted by Leed
You cannot choose a pageId in advance, and there is no argument for one. Leed’s page input schema explicitly removes pageId from what a caller may supply, and the create path generates a fresh UUID server-side. This is the single thing every bulk-import reader gets wrong first, and it is what forces the two-pass shape: reserve all, harvest the returned ids, then author bodies from that map.
The practical consequence is that a plan written before the import — “page A links to page B” — has to be resolved into real ids at authoring time. If your process generates bodies from a template, leave a placeholder token in the template and substitute the harvested ids in a second pass.
The slug you pass is ignored
create_page advertises an optional slug, and it has no effect. The create path computes the slug from the title with slugify, de-duplicating against existing pages by appending -2, -3, and then spreads that computed value after your data — so a supplied slug is overwritten every time.
Slugs are constrained to lower-case alphanumerics and single hyphens (^([a-z0-9]+(-[a-z0-9]+)*)?$), and titles to 2–150 characters.
Where the page lands
A page created on a documentation or API page type is appended to that page type’s bound left-nav menu automatically — at top level, with a flat path. It cannot be created inside a folder over MCP: the create route accepts a menuFolderId, but the create_page tool’s transform passes only pageTypeId, title and slug, so the parameter never reaches it.
update_page_draft cannot fix this either. Its schema lists path, but the backing route deliberately strips the field before validation — the docs menu is the single writer of a docs page’s folder ancestry, and a path persisted without a matching menu save would desync the published file location from the recorded URL. The field is dropped silently rather than rejected, so a call that sets it appears to succeed and changes nothing.
Folder placement is a docs-menu save and nothing else. Building the tree with update_menu_draft recomputes and stages a path change for every affected page in one operation. This is the customer-facing half of the rule that a docs page’s URL is its folder location: the menu decides, the page follows. Folders Set Your URLs is the full chain, and Left Navigation Menu covers the tree itself.
For a whole documentation set the sequence is therefore: create all pages flat → author bodies → one update_menu_draft carrying the complete nested tree → a person publishes the menu and the pages.
Step 2 — link with pageid:
Internal links use Leed’s own URI scheme rather than a path:
See [Connecting a client](pageid:7fffefb3-154b-481e-a818-0fb1f4bcbd04) for the sign-in.The site build resolves each pageid: reference to the target’s current path at render time. A hand-written /docs/… href gets none of that: no rewrite when the page moves, no move-tracking, and no dead-link handling.
When a pageid: target is unpublished or deleted, the build replaces the anchor with <span class="page-removed"> — the link text survives on the page, the link does not. That span is the thing to grep for after an import; it is the signature of a link to a page that never got published.
Linking to a section
A fragment is supported and works today:
[The two confirm-gated tools](pageid:9418dcdf-e254-4b3f-b4d7-ef5406deed76#the-two-tools-that-stop-and-ask)The transform preserves the hash and composes path → query → hash, so the reader lands on the named heading. Use the fragment form when the useful destination is one section of a long reference page, and the bare form when the whole page is the answer. Anchor slugs come from the heading text, so only link to a heading you have confirmed exists. The rest of the link syntax is on Links and Internal Links.
Step 3 — fill an empty body
fill_page_markdown { pageId, markdown } sets the page’s entire body from Leed Markdown. It is allowed only while the body is still empty. On a page that already has content it returns a 400:
Page body is not empty. Read the current content via get_page_markdown and use
apply_page_markdown_ops to make anchored edits instead.Send raw Leed Markdown — not a fenced code block wrapping it, and not escaped. New blocks need no data-id; ids are assigned on import. Overview and Cheat Sheet has one example of every feature, and Markdown for AI, MCP and Import covers the constraints that only bite an automated writer.
Filling is a one-shot
This is worth stating as a cost rather than as a rule. Once a body is non-empty the only way to change it is apply_page_markdown_ops, and that path lands tracked suggestions requiring human accept or reject — it does not overwrite. So a body that is wrong on the first write is not repaired by a second call; it becomes a review queue for a person.
Get each body right the first time. For a set being generated in bulk, it is usually cheaper to reserve a fresh page and reassign the menu leaf than to patch a bad body block by block.
Step 4 — edit a non-empty body
Editing is a read-plan-apply loop, and every step of it exists to make concurrent human editing safe.
Read the anchors
get_page_markdown { pageId } returns { markdown } — the body with every top-level block carrying its {data-id="…"} anchor:
## How connections are authorized {data-id="b3f1a2c8"}
Every call runs under your own role. {data-id="7d0e4411"}Only anchored top-level blocks are addressable. The markdown reflects the clean accepted-state body, so a deletion that is still pending as somebody’s suggestion is not shown.
Apply ordered ops
apply_page_markdown_ops { pageId, ops } takes at least one op and applies them in order. There are four:
| Op | Required fields | Effect | Rejected when |
|---|---|---|---|
insertAfter | anchorId, markdown | Adds new blocks immediately after the anchor block | markdown is empty or whitespace; the new blocks carry no markable text |
insertBefore | anchorId, markdown | Adds new blocks immediately before the anchor block | Same as insertAfter |
replace | anchorId, markdown | Swaps the anchor block for the new markdown | Same as insertAfter; also when the anchor block itself carries no markable text |
delete | anchorId | Removes the anchor block | The anchor block carries no markable text |
Indices are resolved against the document as it was read, and the new block list is built in a single pass, so an insert never shifts a later anchor. Each anchor may be targeted by at most one op in a set.
The four ways a whole set is rejected
Every one of these is checked before any mutation, and every one of them rejects the entire op-set with a 409 rather than applying part of it. That is what makes the loop safe to run against a page a colleague has open.
| Condition | Message |
|---|---|
| An anchor no longer exists | stale anchor: data-id "…" not found; re-read the page with get_page_markdown and re-plan |
| Two ops target the same anchor | duplicate anchor: data-id "…" is targeted by more than one op; use one op per anchor |
| The anchor already carries a pending suggestion | anchor "…" already has a pending suggestion; resolve it (accept/reject) and re-read with get_page_markdown before editing it again |
| A block involved has no markable text | An untrackable-block error naming the block |
The recovery for the first three is the same: re-read with get_page_markdown and re-plan against the anchors that came back. Do not retry the same op-set.
The fourth is different in kind. An image-only or iframe-only block has no text leaf to hang an addition or deletion mark on, so it cannot surface as an accept/reject suggestion at all. Leed rejects it rather than applying an untracked change behind your back. There is no MCP workaround — that block has to be edited by a person in the editor.
One more failure is worth knowing: if the page is locked because a publish is in flight, the call comes back 409 Page is locked for publishing. Wait for the deployment and retry.
What lands in the editor
Changes arrive as tracked suggestions attributed to the connected user — insertions and deletions marked up exactly like a colleague’s suggested edit, reviewed and accepted or rejected in place in the page editor. They are written into the same real-time collaborative document your team edits together, so nothing is pushed into an open session: a teammate with the page open sees them when the document next syncs, and a teammate who is not in the page sees them on their next load.
Accepting and rejecting is a normal editor task, described in Suggesting Mode and Tracked Changes. Until somebody does it, the page’s published content is unchanged.
Metadata is a different tool
update_page_draft changes title, slug, summary, keywords, labels, authors, planned date, SEO card, feature image and everything else on the page record — and never touches the body. It is the tool to reach for after a fill, not instead of one. Its full field table is on MCP Tools: Pages and Content, and the same fields as the editor presents them are on Page Settings.
Which tool when
| Situation | Tool | Precondition |
|---|---|---|
| Reserving a page so others can link to it | create_page | Page type exists and is unlocked |
| Filling a body for the first time | fill_page_markdown | Body is still empty |
| Editing a body that has content | apply_page_markdown_ops | Anchors read in the same session with get_page_markdown |
| Changing title, slug, labels, SEO, dates | update_page_draft | None |
| Creating a page and its body in one call | create_page_draft | You do not need to link to pages that do not exist yet |
create_page_draft { title, pageTypeId, markdown } is the one-shot: it creates the page and sets its body together, and also accepts labels and a plannedDate. It is the right tool for a single standalone page. It is the wrong tool for a multi-page set, because the body it writes cannot contain a pageid: link to a page that has not been reserved yet — and it is a fill, so you get one attempt at it.
A worked sequence
Three pages that cross-link, then one correction to a body that has already been written.
The full call sequence, three pages
→ list_page_types {}
← { pageTypes: [ { pageTypeId: "u05nxr", slug: "docs", name: "Documentation", type: "documentation" }, … ] }
PASS 1 — reserve everything, harvest the ids
→ create_page { pageTypeId: "u05nxr", title: "Installing the CLI" }
← { page: { pageId: "a1b2c3d4-…", title: "Installing the CLI" } }
→ create_page { pageTypeId: "u05nxr", title: "Authenticating" }
← { page: { pageId: "e5f6a7b8-…", title: "Authenticating" } }
→ create_page { pageTypeId: "u05nxr", title: "Your First Deployment" }
← { page: { pageId: "c9d0e1f2-…", title: "Your First Deployment" } }
PASS 2 — author bodies, using the harvested ids for cross-links
→ fill_page_markdown {
pageId: "a1b2c3d4-…",
markdown: "Install the CLI, then [sign in](pageid:e5f6a7b8-…).\n\n## Requirements\n\nNode 20 or later.\n"
}
← { page: { pageId: "a1b2c3d4-…" } }
→ fill_page_markdown {
pageId: "e5f6a7b8-…",
markdown: "Sign in once per machine.\n\nWith the CLI installed you are ready for [your first deployment](pageid:c9d0e1f2-…).\n"
}
← { page: { pageId: "e5f6a7b8-…" } }
→ fill_page_markdown { pageId: "c9d0e1f2-…", markdown: "…" }
← { page: { pageId: "c9d0e1f2-…" } }
LATER — correcting a body that is no longer empty
→ get_page_markdown { pageId: "a1b2c3d4-…" }
← { markdown: "Install the CLI, then [sign in](pageid:e5f6a7b8-…). {data-id=\"4f21aa\"}\n\n## Requirements {data-id=\"8c07b3\"}\n\nNode 20 or later. {data-id=\"1de590\"}\n" }
→ apply_page_markdown_ops {
pageId: "a1b2c3d4-…",
ops: [ { type: "replace", anchorId: "1de590", markdown: "Node 22 or later." } ]
}
← { page: { pageId: "a1b2c3d4-…" } } # a tracked suggestion, awaiting reviewTwo things this sequence does not do, because they cannot be done over MCP: it does not place the three pages in a folder — that is a later update_menu_draft carrying the whole tree — and it does not publish anything. Publishing is a person’s action in the CMS, described in Publishing Changes.
For the full boundary of what a connected client can and cannot reach, see What the Operator MCP Can and Cannot Do; the parameter tables for all five body tools are on MCP Tools: Pages and Content.