Thirteen of the one hundred and one Operator MCP tools work on pages. Two create them, five read them, two write bodies, one changes metadata and three search published 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.
Twelve of the thirteen are available to the Leed Assistant as well as to MCP clients — list_page_sync_state is MCP only — and all thirteen 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 and API 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 or API 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.
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. 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 non-documentation type clears its folder ancestry.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; publish withpublish_page_noworschedule_pages_by_id. 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.
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 |
status | enum | DRAFT, IN_REVISION, APPROVED, SCHEDULED, REPLACED — never PUBLISHED |
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.
Searching your own content
search_documentation, search_posts and search_api are one search engine behind three hard-scoped views. The page-type kind is baked into each tool’s route rather than passed as an argument, so a model cannot cross from documentation into blog posts 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 |
All three 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 kind; 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 published revisions | 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 |
Only published pages are searchable. A draft an AI just created will not appear until somebody publishes it, which surprises people mid-import; use list_pages to see drafts.
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.