Sixteen of the one hundred and five Operator MCP tools work on pages. Two create them, five read them, two write bodies, one changes metadata, one locks internal pages, one deletes them and four search your content. This page is the parameter reference: what each tool takes, what it gives back, and where the sharp edges are. Publishing a page, now or on a schedule, is on MCP Tools: Publishing, Distribution and Users.
It is deliberately not the workflow. The order these tools must be called in, when a body can be filled and when it can only be edited, and what happens when an anchor goes stale are all on Authoring Pages Over MCP — read that first if you are writing content rather than looking up a field. Every tool name here is also in the alphabetical Operator MCP Tool Index.
Fifteen of the sixteen are available to the Leed Assistant as well as to MCP clients — list_page_sync_state is MCP only — and all sixteen dispatch to the same permission-checked API routes the CMS itself calls, so your role is the ceiling on every one of them.
Creating pages
reserve_page
POST /api/page · Returns { page: { pageId, title, slug, url, status, pageTypeId }, menuPlacement? }, also delivered as structuredContent · Available in the assistant
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
pageTypeId | string | Yes | — | From list_page_types. A locked page type refuses new pages |
title | string | Yes | — | 2–150 characters. The slug is derived from it when you send none |
slug | string | No | title-derived | Used verbatim. See below |
menuFolderId | string | No | top level | Documentation, API and internal types only: an existing folder in the type’s own left-nav menu. Rejected for posts |
menuIcon | string | No | — | CSS class string for the nav entry, e.g. fa-solid fa-book. Rejected for posts |
menuIconStyle | string | No | — | Inline CSS for the icon; requires menuIcon. Rejected for posts |
sourceId | string | No | — | Tags the page with your own source commit; see Authoring Pages Over MCP |
Creates the page in DRAFT status with an empty body and hands back its pageId. That id is minted server-side and cannot be supplied, which is what makes a multi-page import a two-pass job.
{ "pageTypeId": "u05nxr", "title": "Installing the CLI" }{
"page": {
"pageId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"title": "Installing the CLI",
"slug": "installing-the-cli",
"url": "/docs/installing-the-cli/",
"status": "DRAFT",
"pageTypeId": "u05nxr"
}
}A documentation, API or internal page is appended to that page type’s bound left-nav menu — at the top level, or inside the folder menuFolderId names. The append is best effort: it never fails the create, and a menuPlacement object in the response means the entry did not land as a fresh append (present, unavailable or failed). The page exists either way, so repair the nav with add_menu_item or move_menu_item rather than creating the page again. How folders become URLs is in Folders Set Your URLs.
create_page_from_markdown
POST /api/page/from-markdown · Returns the created page · Available in the assistant
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
title | string | Yes | — | 2–150 characters |
pageTypeId | string | Yes | — | From list_page_types |
markdown | string | Yes | — | Leed Markdown, sent raw — not fenced, not escaped |
slug | string | No | title-derived | Same rules as reserve_page. Use it to preserve an existing URL on import |
labels | string[] | No | — | Label ids |
plannedDate | string | No | — | ISO date, YYYY-MM-DD |
menuFolderId, menuIcon, menuIconStyle | string | No | — | Same nav placement fields as reserve_page |
sourceId | string | No | — | Tags the page with your own source commit |
The one-shot: creates the page and sets its body in a single call. Right for a standalone page, wrong for a set that cross-links, because the body it writes cannot reference a page that has not been reserved yet. Markdown using a formatting feature the page type disables is rejected with a 400 and nothing is created, and so is a body holding more than 50,000 table cells (markdown.too_many_table_cells); a warning in the response names anything the import dropped — a stripped {...} attribute group, a user-written id, or a line whose words were lost — and a validation array lists anything the write changed or dropped without blocking it.
The markdown you send is Leed Markdown; Overview and Cheat Sheet has one example of every feature. Raw HTML does not fail the call: a tag is kept as literal text and shows on the page as typed, and an HTML comment is stripped. A user-written id ({#x} or {id="x"}) is not supported on any element and is dropped.
Reading pages
get_page
GET /api/page/:pageId · Returns { page, aliases, rbacOverrides, openCommentCount } · Available in the assistant
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
pageId | string | Yes | — | — |
includeOpenCommentCount | boolean | No | true | Count the body’s open comment threads into openCommentCount |
Metadata, status, labels, dates and SEO fields. The body is not included — that is get_page_body. openCommentCount counts open comment threads in the body, emoji reactions included; when it is above zero, read them with get_page_review_state.
get_page_body
GET /api/page/:pageId/markdown · Returns { markdown, blocks }, also delivered as structuredContent · Available in the assistant
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
pageId | string | Yes | — | — |
Returns the body as Leed Markdown with every top-level block carrying its {data-id="…"} anchor, plus blocks — one { dataId, hash } per addressable block. blocks is the set update_page_body may target, and each hash is the baseHash token a replace or delete on that block must carry. Both must come from a read you just made — never from memory and never invented.
{
"markdown": "## Requirements {data-id=\"8c07b3\"}\n\nNode 20 or later. {data-id=\"1de590\"}\n",
"blocks": [
{ "dataId": "8c07b3", "hash": "v4:4f0c1a9e2b7d3c56" },
{ "dataId": "1de590", "hash": "v4:9a2e61d0c4b87f13" }
]
}The markdown reflects the clean accepted-state body: a deletion that is still pending as somebody’s tracked suggestion is not shown.
get_page_review_state
GET /api/page/:pageId/review-state · Returns { pageId, counts, blocks } · Available in the assistant
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
pageId | string | Yes | — | — |
status | enum | No | open threads | Which comment threads to return |
includeTypes | array | No | ["text"] | Add "emoji" to include emoji reactions |
One entry per block that carries review state — open comment threads, pending suggestions or both — in document order. Each entry gives the block’s dataId, markdown, text and hash, its comments and suggestions, and four flags — canReplace, canReplaceDirect, canInsertBefore and canInsertAfter — that say which update_page_body op the write would accept on that block. It is read-only: nothing on the MCP surface resolves a comment or a suggestion.
list_pages
GET /api/page · Returns the pages visible to you · Available in the assistant
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
labelId | string | No | — | Narrow to one label |
pageTypeId | string | No | — | Narrow to one page type |
Both filters are optional and independent. The list is already scoped by your role, so a Read Only connection sees a shorter list than an Administrator’s — that is the permission model, not a bug. Managing Pages covers the same list as the CMS presents it.
list_page_sync_state
GET /api/page/state · Returns { rows, count, remaining, limit, offset } · MCP only
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
pageTypeId | string | Yes | — | The page type to report |
limit | integer | No | 500 | Rows per call, capped at 500 |
offset | integer | No | 0 | Rows to skip; page while remaining is above zero |
One row per page in the page type: pageId, path, sourceId, status, scheduledFor, updatedAt, updatedBy, pendingSuggestionCount and assets. It is built for keeping an external documentation set in sync without reading every page. path here is the public site path, unlike the path other page responses return, which is folder ancestry. updatedAt and updatedBy move on metadata writes only, and pendingSuggestionCount can lag the editor by up to a minute — treat both as triage, not as a guard.
Writing bodies
set_page_body
POST /api/page/:pageId/content/fill · Returns { page: { pageId } }, also delivered as structuredContent · Available in the assistant
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
pageId | string | Yes | — | — |
markdown | string | Yes | — | The complete body. Sent raw |
overwrite | boolean | No | false | Replace a non-empty body. Allowed only on a page that has never been published |
sourceId | string | No | — | Tags the page with your own source commit |
New blocks need no data-id — ids are assigned on import. On a posts page type, markdown using a formatting feature the type disables is rejected with a 400 naming the offending features. On any page type, a body holding more than 50,000 table cells is rejected with a 400 (markdown.too_many_table_cells). Anything the import drops without blocking the write is named in a warning on the response. A LOCKED internal page refuses body writes, like a PUBLISHED one, until update_page_metadata puts it into IN_REVISION.
update_page_body
POST /api/page/:pageId/content/ops · Returns { page: { pageId } }, also delivered as structuredContent · Available in the assistant
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
pageId | string | Yes | — | — |
ops | array | Yes | — | At least one op, applied in order |
mode | direct or suggest | No | direct over MCP, suggest in the assistant | Pass it explicitly on every call |
sourceId | string | No | — | Tags the page with your own source commit, in either mode |
Each op is one of four shapes. insertAfter, insertBefore and replace take anchorId and non-empty markdown; delete takes anchorId alone. Every replace and delete must also carry baseHash — the block’s hash from your last get_page_body — or the call is a 400; an insert may carry one, and it is enforced when it does.
{
"pageId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"mode": "suggest",
"ops": [
{ "type": "replace", "anchorId": "1de590", "baseHash": "v4:9a2e61d0c4b87f13", "markdown": "Node 22 or later." },
{ "type": "insertAfter", "anchorId": "8c07b3", "markdown": "Bun 1.3 is also supported." }
]
}In direct mode the edits change the body immediately and are not recoverable; in suggest mode they land as tracked suggestions attributed to the connected user. Over MCP direct is the default, so decide which you want before the first edit and pass mode every time. A replace gives the new block a fresh data-id, so re-read before targeting it again. If any anchor is missing, its block has changed since you read it, or it is otherwise refused, the entire op-set is rejected and nothing is applied. The op-set is also refused with a 400 when the body it would produce holds more than 50,000 table cells, even if each op’s own markdown is under that. A LOCKED internal page refuses body writes, like a PUBLISHED one, until update_page_metadata puts it into IN_REVISION. The 409s and the recovery for each are on Authoring Pages Over MCP; what your team sees of a suggestion is on Suggesting Mode and Tracked Changes.
Changing metadata
update_page_metadata
PUT /api/page/:pageId · Returns the updated page · Available in the assistant
pageId is the only required field; every other field is optional and only the ones you send are written. These are the same fields the editor’s right rail exposes, described for humans on Page Settings.
Four of them behave in ways worth knowing before you send a bulk update:
slug— changing it recomputes the page’s URL and stages anextpath row for the next publish. The value is de-duplicated on the way in, so the slug you get back may carry a-2suffix. Changingtitlealone does not re-slug an existing page; only the create tools derive a slug from a title.pageTypeId— moving a page to another page type also recomputes its path, and moving it to a type with no left-nav menu (a posts type) clears its folder ancestry. A page cannot move between an internal page type and any other kind; that is a409.path— present in the tool’s schema and silently dropped by the route. The docs menu is the only writer of a docs page’s folder ancestry; a path persisted here would desync the published file location from the recorded URL. A call that sets it appears to succeed and does nothing.status— cannot be set toPUBLISHED,SCHEDULEDorLOCKED: publish withpublish_page_noworschedule_pages_by_id, and lock an internal page withlock_page. Any metadata change to a published page moves it toIN_REVISION, except a call that sends onlysourceId, which stamps the page and leaves its status alone. ALOCKEDinternal page refuses every metadata change exceptstatus: "IN_REVISION", which revises it — seelock_pagebelow.
Every field update_page_metadata accepts
| Field | Type | Notes |
|---|---|---|
pageId | string | Required. Which page to update |
title | string | 2–150 characters. Does not change the slug of an existing page |
slug | string | Lower-case alphanumerics and single hyphens, max 150. De-duplicated; recomputes the URL |
pageTypeId | string | Moves the page to another page type and recomputes its path. Never across the internal line (409) |
status | enum | DRAFT, IN_REVISION, APPROVED, REPLACED — never PUBLISHED, SCHEDULED or LOCKED. IN_REVISION on a LOCKED internal page revises it |
sourceId | string | Your own source commit for the page. Sent alone, it stamps without changing status |
summary | string | Required at publish on documentation page types |
keywords | string[] | Rendered as meta keywords |
labels | string[] | Label ids. Also drives label-scoped permission overrides |
authors | string[] | User ids shown as bylines |
contributors | string[] | User ids shown alongside authors |
publishedAt | date | Drives collection sort order, sitemap <lastmod>, feed dates and JSON-LD datePublished |
plannedDate | date | Editorial calendar date; controls display order and scheduling |
dateFormat | string | Display format for plannedDate, e.g. MMMM yyyy |
hasBeenPublished | boolean | Whether this page has ever been live |
formId | string | Form to render on the page |
journeyId | string | Links the page to a journey for attribution |
featureImage | object | { assetId, src, width, height, alt, svg } — assetId, src, width, alt and svg are all required together |
featureVideo | object | { assetId, videoId, videoOptions } |
ogCard | object | { title (≤70), description (≤240), image } |
sitemapPriority | number | 0.0–1.0 |
wordCount | number | Normally computed on save; settable but rarely useful |
extraFrontmatter | object | Unmodeled frontmatter passed through verbatim on publish |
disableCta | boolean | Suppress the page’s call to action |
disableAutolink | boolean | Exclude the page from autolink rewriting |
eleventyExcludeFromCollections | boolean | Keep the page out of site collections and listings |
private | boolean | Restrict who can see the page |
previewOnly | boolean | Excludes the page from production builds and from vector indexing. Defaults false |
path | string | Dropped by the route. Folder placement is a docs-menu save |
Two notes on that table. previewOnly is documented because it is in the schema, not because it is a recommended workflow — there is no CMS control for it, and a page carrying it is absent from your production site and from the search index that search_documentation queries. This documentation set sets it on zero pages.
And modifiedAt is not settable. It is stamped server-side on every metadata write, so an import cannot backdate it. publishedAt is settable and is load-bearing for sort order, sitemaps, feeds and structured data — an AI creating pages should set it deliberately rather than leave it for somebody to notice later.
Locking internal pages
lock_page
POST /api/page/:pageId/lock · Returns { page } · Available in the assistant
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
pageId | string | Yes | — | A page of an internal page type |
Locks the current revision of an internal page — the same thing Lock revision does in the editor. An internal page is never published or scheduled — publish_page_now and both schedule tools refuse it with internal_page_not_publishable — so a locked revision is its settled version, the one your team reads. Locking also indexes the page for search_internal. Any other page is refused with a 400; publish it instead. Only a DRAFT, IN_REVISION or APPROVED page can be locked, so locking a page that is already locked is a 409. The tool needs page:publish, as publishing does, but it is low risk and not confirm-gated, because nothing it does reaches your site. How internal page types, their menus and their pages work in the CMS is on Internal Content.
A LOCKED page refuses body writes and every metadata change until it is revised. To revise it, call update_page_metadata with status: "IN_REVISION": that opens the next version as IN_REVISION, exactly as Revise does in the editor, and any other fields sent in the same call land on the new version. Revising needs page:publish too.
Deleting pages
delete_page
POST /api/page/:pageId/delete · Returns a preview, or { deleted, pageId, deletion, redirect, page } once confirmed · confirm-gated (route mode) · requires page:publish · Available in the assistant
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
pageId | string | Yes | — | The page to delete |
redirectTo | string | For a published page, to confirm | — | "404", or the pageId of the published page its live URLs should redirect to. Refused with a 400 for a page that was never published, or an internal page |
menuIds | string[] | No | — | Further menus to remove the page from, beyond its page type’s own left-nav menu |
confirm | boolean | No | false | Omit for a preview. Pass true only after the user has approved it |
What a delete does depends on the page, which is why the preview comes from the route rather than from your arguments. A published page is soft-deleted: it leaves the live site on the next published build, exactly as a delete from the editor does. A page that was never published is deleted permanently and at once, and so is an internal page, whose entries also leave the internal search index. Neither of those can be undone.
The first call, without confirm: true, changes nothing. It returns preview: true, the page (pageId, title, path, status), deletion (soft or permanent), an effect sentence, the liveUrls the page answers on (its current path and any aliases), what those URLs will become, and menus — the menus it will be removed from (pruned) and any other menus that still link to it (otherReferences). For a published page with no redirectTo yet, the preview says a choice is required and tells the model to ask the user for it rather than choose. When redirectTo is already on the preview call, the preview confirms it: the URLs redirect to the named page, or return a 404.
The second call, with the same arguments plus confirm: true, performs that delete. For a published page it is refused with a 400 unless redirectTo is given, and nothing changes. A redirect turns every live URL of the deleted page into an alias of the target on the next published build; "404" lets them return a 404. The target must be a published, non-internal page in the same workspace, managed by the CMS, and not the page being deleted; any other target is refused with a 400 naming the reason, on the preview and on the confirm alike.
The page is always removed from its page type’s own left-nav menu, and from any menu in menuIds. Both calls need page:publish on the page, so a caller without it gets the 403 on the preview too, and a missing or already-deleted page is a 404 on both. In the assistant the same tool parks behind an approval card, and approving it runs the confirmed call. What the editor’s own delete dialog offers is on Managing Pages.
Searching your own content
search_documentation, search_posts, search_api and search_internal are one search engine behind four hard-scoped views. The scope is baked into each tool’s route rather than passed as an argument, so a model cannot cross from documentation into blog posts, or from published content into internal pages, by changing a parameter.
| Tool | Searches |
|---|---|
search_documentation | Published documentation pages |
search_posts | Published posts — blog and article pages |
search_api | Published API reference pages |
search_internal | Internal pages — the content of internal page types, which is never published |
All four take the same arguments.
GET /api/page/content-search · Returns ranked pages with snippets · Available in the assistant
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
query | string | Yes | — | 2–200 characters |
mode | enum | No | hybrid | freetext, vector or hybrid |
limit | integer | No | 10 | 1–50 |
pageTypeIds | string[] | No | — | Narrows within the tool’s scope; can never widen it |
pageTypeIds is the versioning axis: it is intersected server-side with the page types the tool’s kind already resolves to, so passing a blog page type to search_api narrows the search to nothing rather than escaping into blog content.
| Mode | How it matches | When to use it |
|---|---|---|
freetext | Full-text search over the pages in the tool’s scope | Exact terms, product names, error strings, anything you would quote |
vector | Embeds the query and searches the workspace’s vector index by meaning | Conceptual questions where the page never uses your words |
hybrid | Runs both and merges them with reciprocal rank fusion, deduplicated by page | The default, and the right answer almost always |
The first three search only published pages, and never return an internal page. A draft an AI just created will not appear until somebody publishes it, which surprises people mid-import; use list_pages to see drafts. search_internal searches internal pages as they were last indexed — when the page was locked, and when an editing session on it ended — and never returns a documentation, post or API page. Its vector search runs against an index kept apart from your site’s. Semantic search is not gated by plan. The same search from the editor’s Internal section is described on Searching Content from the Left Panel, and internal content as a whole on Internal Content.
This is the same engine that powers search on your published site, described from the reader’s side on Live Documentation Search.
Which fields a page type requires before any of these pages can be published is set per type; Configuring a Page Type covers that, and the full boundary of what a connected client may do is on What the Operator MCP Can and Cannot Do.