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 — applied directly, or left 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 set wholesale while it is still empty — or, with overwrite, while the page has never been published. Both rules 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 may no longer be 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: reserve_page × N
L-->>M: a pageId for each — bodies empty
Note over M: Collect every id before writing anything
M->>L: set_page_body — body carrying pageid cross-links
L-->>M: written
Note over M,L: The body is no longer empty
M->>L: get_page_body
L-->>M: markdown with a data-id anchor on every top-level block, plus a baseHash per block
M->>M: Plan ops against those anchors
M->>L: update_page_body (mode, baseHash on every replace and delete)
alt An anchor is gone or its block changed
L-->>M: 409 — nothing applied
M->>L: get_page_body (re-read and re-plan)
else All anchors resolve
L-->>M: Direct edits applied, or tracked suggestions written
H->>H: Accepts or rejects any suggestions 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, type (documentation, api or posts), editorFormattingOptions and formattingEnforced. get_page_type 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. create_page_from_markdown, set_page_body and update_page_body all 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
reserve_page { pageTypeId, title } creates a page in DRAFT status with an empty body and returns { page: { pageId, title, slug, url, status, pageTypeId } }. 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 used as given
reserve_page and create_page_from_markdown both take an optional slug, and a slug you pass is used verbatim. If another page already holds that URL the call is refused with a 409 naming it — an explicit slug is never quietly renamed. Leave slug out and it is derived from the title, de-duplicated by appending -2, -3 and so on; a title that slugifies to nothing at all (CJK, emoji or punctuation only) falls back to page. An explicit slug: "" places the page at the page type’s root, and only one page can sit there.
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 — at the top level, or inside an existing folder when you pass menuFolderId, optionally with a menuIcon. The append is best effort and never fails the create: a menuPlacement object in the response says the entry did not land as a fresh append, and the fix is a menu edit, never a second create.
update_page_metadata cannot move a page into a folder. 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.
After creation, folder placement is a docs-menu edit and nothing else. move_menu_item moves a page or a whole folder in one call, and the menu recomputes and stages a path change for every affected page. 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 any folders with add_menu_item → reserve every page into its folder with menuFolderId → author bodies → publish 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 confirm-gated tools](pageid:9418dcdf-e254-4b3f-b4d7-ef5406deed76#the-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 the body
set_page_body { pageId, markdown } sets the page’s entire body from Leed Markdown. It is allowed while the body is still empty, and — with overwrite: true — on a page that has never been published. Any other non-empty body returns a 400:
Page body is not empty. Read the current content via get_page_body and use
update_page_body 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.
Anything the import discards is named in the response’s warning, and the rest of the body is still applied: a line whose words would be lost, an attribute the block cannot keep, and a user-written id such as {#install} or {id="install"}, which Leed Markdown does not support on any element. Read the warning rather than assuming the body landed as sent.
Overwriting is for drafts only
Until a page is first published, overwrite: true replaces its body wholesale. Pending suggestions are discarded with the old body, but a page carrying open comment threads is refused with a 409, because somebody has read the draft — and the replaced body is not recoverable. Once a page has been published, the only way to change its body is update_page_body.
Get each body right before the first publish. For a set being generated in bulk, an overwrite of an unpublished draft is cheaper than patching it block by block; after publishing, every correction is an anchored edit.
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_body { pageId } returns { markdown, blocks } — the body with every top-level block carrying its {data-id="…"} anchor, and one { dataId, hash } per addressable block:
## How connections are authorized {data-id="b3f1a2c8"}
Every call runs under your own role. {data-id="7d0e4411"}blocks is the set you may address, and each hash is the baseHash a replace or delete on that block must carry — a per-block compare-and-swap, so your edit is refused rather than applied if somebody changed that block since you read it. The markdown reflects the clean accepted-state body, so a deletion that is still pending as somebody’s suggestion is not shown. get_page_review_state lists a page’s open comments and pending suggestions per block, with flags saying which op each block would accept.
Apply ordered ops
update_page_body { pageId, ops, mode, sourceId } takes at least one op and applies them in order. mode decides what an edit is: direct changes the body immediately and is not recoverable, while suggest lands tracked suggestions attributed to the connected user. Over MCP the default is direct, so choose before the first edit and pass mode every time. sourceId records the source commit the edit was written against, in either mode. There are four ops:
| Op | Required fields | Effect |
|---|---|---|
insertAfter | anchorId, markdown | Adds new blocks immediately after the anchor block |
insertBefore | anchorId, markdown | Adds new blocks immediately before the anchor block |
replace | anchorId, markdown, baseHash | Swaps the anchor block for the new markdown, which gets a fresh data-id |
delete | anchorId, baseHash | Removes the anchor block |
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. A replace re-keys its block, so re-read before you target the new content.
The ways an op-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.
| Message begins | What it means | Recovery |
|---|---|---|
stale anchor | An anchor no longer exists | Re-read with get_page_body and re-plan |
duplicate anchor | Two ops target the same anchor | Use one op per anchor |
block changed | Somebody edited that block since your read | Re-read, reconcile with their change, re-plan |
unreadable baseHash | The token is not one this server issues, such as a v3: token from before the v4: format | Re-read and use the fresh hashes |
anchor "…" already has a pending suggestion | A replace or delete on a block carrying a suggestion, in suggest mode or on a published page | Have it resolved in the editor, then re-read |
pending split/join boundary | The op would change the block a pending split or join merges into | Anchor elsewhere, or have it resolved first |
open comment thread | A direct replace or delete would destroy an unresolved comment | Answer in suggest mode or insert beside the block |
hidden block | The block is covered by an unresolved deletion suggestion | A person resolves it in the editor |
block not addressable | The editor’s resolved-suggestion history | Edit it in the editor |
ambiguous anchor | The page carries that data-id on two blocks | Open the page in the editor once, then re-read |
A horizontal rule is an ordinary block: it is listed in blocks with its own hash, and every op works on it. A rule stored without a data-id, like any block without one, is not listed and cannot be addressed.
Do not retry the same op-set after any of them. The flags get_page_review_state reports — canReplace, canReplaceDirect, canInsertBefore and canInsertAfter — tell you in advance which of these a block would raise.
One more applies to suggest mode only. A block with no text and no attribute-based tracking — an empty paragraph or heading, or a paragraph whose only content is an icon — cannot be expressed as a suggestion, so it is rejected there; direct mode replaces and deletes it. Image, iframe and form blocks track through an attribute and work in both modes.
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.
A body has a size limit for tables as well: an op-set is refused with a 400 when the body it would produce holds more than 50,000 table cells, even though each op’s own markdown is under that. The message names the limit; Tables explains how cells are counted and what to do instead.
What lands in the editor
In suggest mode, 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. In direct mode they are simply the new body. Either way 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. Neither mode changes the published site: on a published page an edit lands on the revision, and the live content stays as it was until that revision is published.
Metadata is a different tool
update_page_metadata 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. It cannot set PUBLISHED, and any change it makes to a published page moves that page to IN_REVISION, except a call that sends only sourceId. 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 | reserve_page | Page type exists and is unlocked |
| Filling a body for the first time | set_page_body | Body is still empty, or overwrite on a never-published page |
| Editing a body that has content | update_page_body | Anchors and hashes read with get_page_body |
| Changing title, slug, labels, SEO, dates | update_page_metadata | None |
| Creating a page and its body in one call | create_page_from_markdown | You do not need to link to pages that do not exist yet |
create_page_from_markdown { title, pageTypeId, markdown } is the one-shot: it creates the page and sets its body together, and also accepts slug, labels, a plannedDate, the nav placement fields and sourceId. 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.
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
→ reserve_page { pageTypeId: "u05nxr", title: "Installing the CLI" }
← { page: { pageId: "a1b2c3d4-…", title: "Installing the CLI", slug: "installing-the-cli", … } }
→ reserve_page { pageTypeId: "u05nxr", title: "Authenticating" }
← { page: { pageId: "e5f6a7b8-…", title: "Authenticating", slug: "authenticating", … } }
→ reserve_page { pageTypeId: "u05nxr", title: "Your First Deployment" }
← { page: { pageId: "c9d0e1f2-…", title: "Your First Deployment", slug: "your-first-deployment", … } }
PASS 2 — author bodies, using the harvested ids for cross-links
→ set_page_body {
pageId: "a1b2c3d4-…",
markdown: "Install the CLI, then [sign in](pageid:e5f6a7b8-…).\n\n## Requirements\n\nNode 20 or later.\n"
}
← { page: { pageId: "a1b2c3d4-…" } }
→ set_page_body {
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-…" } }
→ set_page_body { pageId: "c9d0e1f2-…", markdown: "…" }
← { page: { pageId: "c9d0e1f2-…" } }
LATER — correcting a body that is no longer empty
→ get_page_body { 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",
blocks: [ …, { dataId: "1de590", hash: "v4:9a2e61d0c4b87f13" } ] }
→ update_page_body {
pageId: "a1b2c3d4-…",
mode: "suggest",
ops: [ { type: "replace", anchorId: "1de590", baseHash: "v4:9a2e61d0c4b87f13", markdown: "Node 22 or later." } ]
}
← { page: { pageId: "a1b2c3d4-…" } } # a tracked suggestion, awaiting reviewTwo things this sequence does not do. It does not place the three pages in a folder — pass menuFolderId to reserve_page, or move them later with move_menu_item. And it does not publish anything. Publishing over MCP is publish_page_now or schedule_pages_by_id, both of which stop for your confirmation first; they are on MCP Tools: Publishing, Distribution and Users, and the CMS side is 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 every page tool are on MCP Tools: Pages and Content.