Launch offer
20% off every Pro tier — and every renewal — for a limited time. Use the code at checkout:
Changelog — 17 releases
Every entry names a user-visible outcome. Refactors, test additions and documentation-only changes are left out unless they alter what an agent can do.
Versions follow semantic versioning. Nothing that is free in one release moves behind the licence in the next.
content-resave stores the page again exactly as it is so all of that works itself out. Nothing in the page changes, and the title, slug, content, excerpt, type and status are read back afterwards to prove it.seo-sitemap-get says which plugin is in charge, whether the sitemap is switched on, its address, how many built copies are stored, and what the site actually answers when that address is asked for. seo-sitemap-flush throws those copies away so the next request builds a fresh one. Both are SiteHelm Pro.site-rewrite-flush throws the stored rules away so WordPress works them out again on the next visit, and the preview says so: that first visit is the slow one. There is nothing to undo, because what was cleared was the out-of-date copy, so no snapshot is taken and no rollback is offered.content-terms-assign is now called content-terms-replace. The operation has always replaced a post's terms in the named taxonomy — categories not sent in the call are removed, not kept — and its description said so, but the name said "assign", which reads as add. An agent skimming names could wipe a post's remaining categories without meaning to. The name now says what the operation does. A site that had switched the operation off under its old name keeps it switched off: the stored switch list maps the old identifier to the new one on read.losesStateWithoutSnapshot, and in the saved export the flag reads "replaces existing state". Anything reading the export by the old name will need to read the new one.content-list and content-search, media ids to media-list, comment ids to comment-list, user ids to user-list, form ids to form-list, and menu item ids to menu-get. The term id on the term SEO operations now also says it is a term id, not a post id — the two are separate sequences, so a post id sent there could land on an unrelated term.parent field only records which post a file was first uploaded to, but its description read like a usage record, inviting "delete everything with parent 0" cleanups that break live pages. The descriptions on media-list and media-delete now say plainly that parent is not usage, that no operation reports usage, and that deletion checks nothing before removing the file.fields out of a call read like "change nothing" and actually deleted every property the post carried. The member is now required, the same way type is, and an omitted fields is refused with a message naming the replace semantics; an empty object remains the explicit way to store none.media-upload, media-import, media-svg-upload and media-upload-ticket — and there is nothing to put back, because nothing was there before. The other two, content-seo-bulk-set and content-seo-audit-fix, change a whole set of posts at once, and the change is filed under the set rather than under any one post, so an undo has no page to aim at. All six now say plainly that no undo is offered. Nothing about what they do has changed; what changed is what they promise, which is what an agent reads before it decides how to work. The four uploads also stop adding a puzzling "no snapshot was captured" warning to every successful call. A bulk SEO run that fails part way through still puts back the posts it had already touched before it returns the refusal. That was never the undo button and is unaffected.site-settings-set rebuilt the stored address rules after moving the permalink structure, and rebuilding them in the same request saves whatever WordPress worked out when the request started — which is the structure we had just moved away from. So the setting changed, the read-back agreed, and the site kept routing the old way. It now clears the rules instead and lets the next visit work them out, which is the same thing site-rewrite-flush does and for the same reason. The restore path had the same fault and is fixed with it.elementor-template-get, elementor-page-settings-get, elementor-element-get, elementor-document-get, content-rendered-read and content-audit-read each declare a member as an object and each sent [] instead of {} when that member was empty. The cases that broke were the ordinary ones: a page whose settings have never been touched, a widget left at its defaults, a page carrying no Open Graph or Twitter tags, an audit row with nothing to summarise. A client that checks the response against the schema it was given had no choice but to reject it, over a state that means nothing worse than "this has not been set". They now answer {}..zip files into its media library automatically, because the Pro install operations need them there. But a licence proves a purchase, not a wish to collect archives of runnable code — and the site owner was never asked. There is now a switch on the SiteHelm Health tab, off until the owner turns it on; until then every zip upload is refused exactly as it is on a free site. Installing from a zip already in the library is unchanged. This is a Pro fix.../secret pointed the read at a folder next to the themes directory — and a plain wp-config.php came straight back, database password and all. A theme name is now accepted only when the site actually has a theme installed under it, checked before any disk is touched.media-delete removes one item and the files behind it. It is the one media operation with no way back: WordPress deletes the uploaded file and every resized copy it made, SiteHelm keeps no copy of either, and undoing it is not offered rather than offered and refused later. So it always previews, and the preview names the file that goes, how many resized copies go with it, and any content using it as a featured image. It always deletes rather than using the media trash, because an operator approving the plan cannot see how the site is configured and the plan has to mean the same thing everywhere.content-write. system-operation-find takes the words and answers with the operations that match, wherever they live, ranked, and it names the ones the add-on carries as well as the ones this site has, so "the add-on does that" is a possible answer instead of silence. It never names an operation the catalogue would have hidden. Every match carries a runnable example call, so finding the operation and calling it are one step rather than two.menu-item-delete removes one item. It is destructive, so it previews, snapshots and reverses like every other destructive write, and putting one back adds it again under a new identifier — which the result says plainly. An item that has items beneath it is refused, naming them, rather than leaving them pointing at a parent that is gone.content-get reported an empty meta on posts covered in custom fields, because that member only ever listed the keys an administrator had allowed SiteHelm to write. A second member, registeredMeta, now reports the fields a theme or plugin registered on the post type and their values. They are readable, not writable, and the response says how to make one writable.system-catalog-export returns every operation this site publishes in one Markdown or JSON document, grouped by subject, with the risk and rollback flags a choice turns on, and it names the ones the add-on carries the same way it names the ones this site has. Each export carries a catalogVersion stamp that only changes when the surface itself does, so a saved copy knows it is stale by comparing that stamp against system-connection rather than re-exporting on a schedule.sitehelm://catalog, so a client that reads resources at connection time gets the same document without spending a tool call on it. A client only learns what a server offers when it connects, so reconnect it after updating SiteHelm or the resource stays invisible to that session; the system-catalog-export operation works either way.media-image-resize on a site that calls it media-resize used to get "not available on this dispatcher" and nothing else, and a client one word away from the answer would report that the site cannot do it. The refusal now names the closest operations the site publishes. They are drawn from the same filtered surface a catalogue listing shows, so a refusal still cannot name anything a listing would have hidden, and an operation held behind a switch is still refused word for word like one that was never registered.retryable no longer says retry on errors that can never succeed. It was true for bad input and expired plans, both of which sit beside a message explaining that sending the same thing again cannot work. The flag now means only what it says: whether this exact request may succeed later. What to do instead is still named in remediation.{arguments: {operation: …}} returned the entire catalog, which reads as success and is not. It now comes back as invalid input naming the nesting.media-upload, media-import, media-svg-upload and the upload ticket were all affected. On the ticket route the fatal escaped altogether: the caller got WordPress's own critical-error page instead of a refusal, the ticket was already spent so the retry needed a new one, and the activity row stayed open at Started for good. The call is fixed, and that route now answers anything that goes wrong inside it with the same readable refusal and closes its activity row either way.content-style-check takes a page, a selector and a width, reads the stylesheets that page links on your own site, and reports every rule written for that selector, whether each one applies at the width you asked about, and which declaration wins. It is a stylesheet reader, not a browser: it does not lay the page out, run scripts, or work out inherited values, and a condition it cannot judge - a dark-mode query, say - is reported as unjudged rather than guessed at, and kept out of the answer about which rule wins. Stylesheets on other sites, such as a font service, are listed as present and left unread.header.php in one theme generation and template-parts/header/site-header.php in the next. system-theme-file-list lists what a theme ships, with each file's size and when it was last written, and system-theme-file-read returns one of them. Both read only, both open to anyone who may administer the site. A path that walks out of the theme is refused twice over — once on how it is written and once on where it actually lands, so a symlink pointing somewhere else on the server reads nothing. Long listings are capped and say so; a file over 256 KB and a file that is not text are refused outright rather than returned half-complete or mangled.media-upload-ticket splits the permission from the payload. It returns a secret, an upload URL and an expiry; the file is then posted straight to that URL as raw bytes with the secret in an X-SiteHelm-Ticket header, so the bytes never pass through the agent at all. A ticket is bound to one site, one operator, one filename and one exact byte length, and optionally to one sha256, so it cannot be redirected at different content; it lasts ten minutes and is spent exactly once, by a database update that reports how many rows it changed, so two requests racing the same ticket produce one upload rather than two. The secret is stored only as a digest, and it is deliberately left out of the fields the operation promises, so it reaches you and stops there instead of being copied into the permanent audit row. When the file arrives, SiteHelm checks again that writes are not paused, that uploads are switched on and that you still hold the capability, and it judges the file by its content exactly as an ordinary upload is judged. The result is filed in the activity log as media-upload, because that is what happened. Nothing changes until a ticket is used, so there is nothing to roll back: ignore it and it expires.plugin-install-upload and theme-install-upload, now install from a zip that is already an attachment in this site's own media library. The only argument is the attachment id: there is still no argument anywhere in the plugin that accepts a web address or a file path. The preview opens the package and shows what it would replace, version against version, the way wp-admin does; installing over something already there copies the old files aside first, so the change can be rolled back; and overwriting the theme that is live is allowed and leaves it live, because that is the case this exists for. Free SiteHelm gains the filter the add-on uses to let a zip into the media library at all — it can widen the accepted types but cannot widen past anything the site itself refuses.menu-create takes a name and makes one empty menu; add items to it with menu-item-create and show it with menu-location-assign. A name the site already uses is refused, the name is reported the way WordPress will actually store it, and undoing the change deletes the menu it made and nothing else.site-settings-set now takes siteIcon and siteLogo, reading the settings reports both, and 0 removes either one. Naming an id that is not an image is refused, an icon smaller than the 512 pixels WordPress needs is refused with its actual size, and a logo is refused on a theme that does not show one rather than being written where nothing would read it.content-list accepts private and any as a status. private lists items published for logged-in readers only, and any lists everything except items in the trash, spelled the way WordPress spells it.content-create and content-update now take menuOrder, reading it back reports it, and listing content carries it for every item, so the order that was asked for can be checked without opening each page. A rollback puts the old position back with everything else it restores.wpcode or code-snippets as the host on code-snippet-write, code-css-write or code-js-write and the snippet becomes a real snippet in that plugin's library, tagged so the owner can see who put it there and editable in the screen they already use. It is stored switched off and SiteHelm will not switch it on. Everything that makes SiteHelm's own snippets survivable — the time limit that switches code back off, the site check, safe mode, the quarantine after a fatal error — belongs to SiteHelm's loader and cannot reach code another plugin runs, so the preview says so plainly and the activation operation refuses by name. One key is still one snippet: a write naming a library for a key another library already holds is refused rather than quietly duplicated.plugin-delete and theme-delete remove the files for good. A plugin that is switched on is refused, so is one the network activated, and so is SiteHelm itself; a theme is refused while it is the live one or while another installed theme is built on it. Both are previewed before anything happens, and the preview says plainly that there is no way back and that the plugin or theme's own database rows stay behind. Only a client connected with full permission can call them.system-plugin-list now carries an onboarding column: "pending" for a plugin that is switched on and still parked, "complete" once its own wizard is finished, and nothing at all for a plugin SiteHelm ships no recipe for. It will not guess — a plugin whose flags this version has never read is reported as unknown rather than as finished, because saying "finished" would be a claim about somebody else's database.redirect-list now returns the other plugin's rules beside its own, tagged with who owns them, and redirect-set warns in its preview when the path you are about to claim is already answered by one of them. It is a warning, not a refusal: writing over the old plugin's rules is exactly what you do when you move a site across. A rule stored as a regular expression is shown to you rather than run, and reported as a possible match rather than a certain one.seo-redirection-list returns SiteHelm's own redirects alongside Rank Math's, tagged with who owns them, so either listing answers what the site actually serves rather than what one plugin happens to hold.integration_unavailable still means a dependency this site does not have active, integration_unlicensed means the operation is part of Pro, and upstream_unavailable means something outside the site was slow or down and the same call is worth repeating. Nothing that already worked changed its answer.content-list asked whether a content type was visible to visitors, so an enquiry log, a testimonial store, or any of the custom types a plugin registers for the admin screens only were refused as though they did not exist. It now asks whether the type has an editing screen and whether the account may edit it, which is the question that was meant all along. Reading one of those items already worked, so listing and reading now work together.perPage instead of count, or any other near-miss, produced a refusal that named the property that was wrong and left the caller to guess again. Every operation now lists the property names it accepts.content-meta-update writes only the fields a site has named, and there was nowhere to name them: the list it read was an option nothing ever wrote, so the operation listed itself as available and then refused every request it was given. The Status screen now has a box for those field names, one per line, and a theme or plugin can add its own through the sitehelm_meta_allowlist filter. Both go through the same rules, so a name SiteHelm would never write — anything starting with an underscore, anything with a space in it — is refused as it is typed rather than sitting in the list looking like it works.1.0 becomes 1, and true and false become 1 and 0.content-rendered-get fetches one published item's own front-end address and reports what came back: the status code, any redirect, the title, meta description, canonical and robots tags, the Open Graph and Twitter tags, the heading outline with a count of H1s, how many images carry no alt text, how many links point inside the site and how many out, and the word count — with the markup itself on request. The address is not something you pass in: there is no property for one, so the only page that can ever be fetched is the permalink of the item you named, on this site's own host. Drafts, password-protected items and post types with no public page are refused before anything is requested, because a fetch that carries no cookies would report them as broken.elementor-element-add takes a preset of full-bleed on a container, which stores the zeroed padding and the full-width content the look actually needs. It is shorthand, never an override: sending your own padding or content width beside it is refused rather than silently losing one of them.elementor-document-get now answers a shorter true tree instead: it drops the deepest levels until the response fits, and reports in a new narrowed member how deep it went and how many elements it left out. An element whose children were dropped still says how many it has, so there is no way to mistake the shortened answer for the whole page. To see what was left out, name an element with the new rootId and get that band back at full depth. The totals and the authoring hints keep describing the whole document either way.target offered an empty string as one of its two choices, and the dispatcher tools never said which of their inputs were required. Validators that check a tool definition before calling it rejected both, and a client running one could not use the tool at all. "Same tab" is now the plain _self you would write in HTML, on the way in and on the way back out; an empty string is still accepted and still means the same thing, but it is deprecated and no longer listed.sitehelm, so adding a second site to the same client overwrote the first. The name now carries the site's host — sitehelm-example-com — everywhere Connect shows it.elementor-global-class-list gave up on the whole set when a single stored entry was in a shape it could not resolve, leaving nobody able to see which entry was at fault. The bad entry is now reported in its place with a short reason, and the rest of the list answers as usual.elementor-document-get reports what a page has earned a note about. Alongside its elements it now returns hints: whether the layout is still the default, whether the two layout records disagree, and how many top-level containers will inherit the kit's padding. The list is always there and is usually empty — it only ever says something the page itself demonstrates, never general advice.srcset. A media setting given an image URL with no media-library attachment stores and renders, but WordPress cannot build srcset or sizes for it, cannot add the wp-image class, and will not lazy-load it — so the page serves one full-size file to every visitor. The plan now carries a warning naming the setting, and a bulk write that degrades many images reports the totals instead of a wall of sentences. It is a warning, not a refusal: the write still happens, because pointing a widget at an image outside the library is a legitimate thing to ask for.content-seo-bulk-set and content-seo-audit-fix shipped in SiteHelm Pro from 0.2.0 and now ship here. Batch size was never a good reason to charge: the free plugin already carries the single-post write each of them repeats, so an agent could always reproduce either in a loop — but only by giving up the one preview, one snapshot and one rollback the batched form performs. That put the safer path behind the licence and the riskier one in front of it. Both operations are unchanged in behaviour; they simply no longer ask for a licence.background_color without background_background, or border_color without border_border, was accepted, saved, read back unchanged, and rendered nowhere. Those writes are now refused before the save, naming the companion setting and the values it accepts, so the fix is one more key in the same request rather than a page that reports success and looks untouched. The check reads only what the widget itself declares, so it leaves alone the groups that genuinely need no switcher — typography and box shadow — and stands aside on anything it cannot read with certainty.canvas or headerFooter was stored, read back correctly, and reported as verified while the page went on rendering with the theme's header, footer and title — the change looked done from every angle except the site itself. Both records are now written together, and rolling back restores both. Reads report the layout the page is really rendered with, and say so when the two records disagree; every page an earlier version set a layout on is in that state, and setting the layout again repairs it. Pages created with a layout are no longer born that way.elementor-document-get now returns the page's own page settings — the layout it renders with, whether the theme title is hidden, and the whole stored row behind both. Reading a page used to answer only its elements, so anything built from that read came out inside the wrong page frame, and there was no way to tell.system-plugin-list and system-theme-list report every plugin and theme the site has installed: version, which are active, which the network activated, which theme is live, and which have an update waiting. The update column is read from WordPress's own last check and stamped with when that check happened, so it is exactly as fresh as the Plugins screen and never sends the site off to wordpress.org just because an agent asked a question.DISALLOW_FILE_MODS or DISALLOW_FILE_EDIT refuses the four file writes by name and keeps the three option flips, because locking file modifications is not the same as declining to activate a plugin.readme.txt in the directory format, with banners, icons and console screenshots under assets-wporg/, ahead of the directory submission.media-svg-upload adds one SVG so Elementor's icon and image controls can point at it. The file that is stored is never the file that was sent: the document is rebuilt from an allowlist of drawing elements, and scripts, event handlers, embedded HTML, stylesheets, external references and entity declarations do not survive that rebuild. Everything removed is reported as a warning and the exact document that will be stored is shown in the preview, so what is approved is what will exist. It is the only path allowed to store markup — the ordinary upload and import operations still refuse SVG outright — and it asks for unfiltered_html as well as upload_files, because storing an SVG is closer to publishing markup than to uploading a photograph. The site's own upload permissions are untouched: nothing about the WordPress media screen changes.elementor-document-build replaces a page's entire layout with one you supply, elementor-document-clear empties a page, and elementor-document-create makes a new page for a layout to live on. All three refuse a layout using a widget this site does not have installed, and refuse any setting key the widget carrying it does not declare, rather than storing it and letting Elementor drop the text silently. Build and clear both preview, snapshot and roll back, and both refuse a request that would leave the page exactly as it already is instead of reporting a change that never happened. A created page is always a draft, so nothing an agent sends can put an unreviewed page in front of visitors, and it is a real Elementor page from the moment it exists — the layout you ask for reaches its page settings, and every other Elementor operation will work on it.elementor-page-settings-get and elementor-page-settings-set read and change the settings a page carries as a whole (its layout template, whether the title shows), elementor-elements-reorder sets the order of one element's children, and elementor-element-label-set names an element in the navigator. Page settings live in their own meta row, so the write snapshots that row and not the document — a rollback puts the settings back and leaves the page's content untouched. The write reaches a closed list of settings and merges into the stored row, so anything SiteHelm does not name survives it, while the read returns the whole row so an agent can see what else is there. A reorder demands the parent's complete list of children, so a request written against a page that has since gained one fails loudly instead of quietly guessing where the new element belongs.elementor-template-list and elementor-template-get read the templates the site has saved, elementor-template-save stores a document or a single element as a new one, elementor-template-apply inserts one into a page, elementor-template-import brings in a template this site did not produce, and elementor-theme-template-create makes an empty theme document. An apply re-mints every element id against the destination page and rebinds the styles that pointed at them, so the same template inserted twice does not collide with itself. Apply and import both check a template against the widgets this site actually has and name the missing ones, rather than writing a tree the page cannot render. A theme document is created with no display conditions, so it shows nowhere until elementor-theme-conditions-set says where.elementor-global-class-list, elementor-global-class-create, elementor-global-class-update, elementor-global-class-delete and elementor-global-classes-reorder cover the reusable style classes Elementor 4 keeps site-wide: read them in cascade order, add one, rename one or merge style properties into it, remove one, and set the order they cascade in. The class set is one snapshotted unit, so every change is reversible as a whole. A write refuses while the editor is holding unpublished class changes instead of overwriting them, and the read reports that divergence as inEditorSync. A delete says how many documents wear the class before it is approved, and never edits a document — so restoring the class restyles every element that wore it.content-search names every post, page and custom post type whose title, content, excerpt or Elementor data mentions a phrase, with a per-field count, a plain-text excerpt of the first occurrence, and a link to each document. The phrase is matched whole, not split into words, so searching for an old company name does not return every page carrying the word "name". Results are filtered document by document against your own WordPress user's edit_post capability, so a search cannot become a way to read drafts you may not open. Elementor pages are found — their text lives in post meta, which WordPress's own search does not read — and reported at the document level, with elementor-element-search naming the individual elements inside one of them. A phrase JSON would store escaped is flagged elementorExact: false rather than presented as a complete answer, and a search broad enough to match most of the site stops at five hundred documents and says so.form-list names every form the site's form plugin holds with its embed shortcode, form-get reads one form's fields (name, type, required) exactly as the stored template declares them, and form-entries-list reads a form's recent entries where the plugin stores any — for Contact Form 7, which delivers each entry by email and stores none, the answer says so plainly instead of erroring. Entries gate on manage_options because a submission can carry a visitor's personal information. Everything is read-only: no form write, no entry deletion. Other form plugins arrive through the same provider seam as an add-on.provider, and precedence across a multi-plugin site is fixed (Yoast, Rank Math, All in One SEO, SEOPress, The SEO Framework, Slim SEO, SureRank). A field a plugin has nowhere to store reads as null and a write to it promises null in the preview instead of failing after the fact.site-settings-read answers the whole thirteen-field allowlist (title, tagline, timezone, date and time formats, posts per page, front page geometry, permalink structure, default comment and ping status, search-engine visibility) typed in one call, and site-settings-set changes any subset of it as one previewed, snapshotted, reversible change. Values are validated strictly at plan time — a timezone must be a real identifier, a custom permalink structure must keep every post's URL unique, a front page must be a published page — and nothing outside the allowlist is reachable. Changing the permalink structure flushes rewrite rules and warns that existing URLs change; turning search-engine visibility off warns that removal from indexes is not guaranteed.content-seo-schema-get, content-seo-schema-set and content-seo-audit-fix (SiteHelm Pro 0.2.0), so a site without the add-on sees them as locked rows in their groups.content-seo-score-get reports one post's SEO and readability scores exactly as the plugin stored them (never recomputed, so the number matches the editor; Rank Math has no separate readability score and says null), plus a list of findings derived from the metadata — missing, too-short or too-long description, over-long title, missing focus keyword or one absent from the title, a published post kept out of search, a score under the caller's floor. content-seo-audit walks a page of posts (default 50, at most 100) and reports each one's scores and findings with a count of every finding across the page, detecting duplicate titles and descriptions within it; posts the caller may not edit are skipped and counted, not shown. The finding vocabulary is fixed and published in both output schemas.content-term-seo-get and content-term-seo-set read and write a category's or tag's title, description, canonical, focus keyword and noindex in Yoast SEO (the wpseo_taxonomy_meta option) or Rank Math (term meta, with the robots directive list edited rather than replaced). Both admit on edit_posts and then ask the taxonomy's own edit capability, so a contributor cannot rewrite what a category archive tells search engines. The write is previewed, snapshotted and reversible like every other. 74 operations.sitehelm_modules (add a module), sitehelm_register_operations (add operations to a module) and sitehelm_status_sections (add a section to the Health tab). Every hook is additive and contained: nothing an add-on returns can remove a built-in module, and a throwing handler is logged rather than allowed to break the boot.manage_options second. seo-settings-get / seo-settings-set read and write an allowlist of the plugin's own settings — separator, knowledge-graph name and logo, default social image and breadcrumbs at site scope; title and description templates, noindex and sitemap inclusion per public post type — as one previewed, snapshotted, reversible change per scope, translating between the two plugins' option layouts (Yoast's separator codes, Rank Math's two-key robots rule). content-seo-bulk-set applies the content-seo-set fields to up to fifty posts at once as a single reversible change, refusing the whole set if one post is missing or not the caller's to edit. seo-404-log-list and seo-redirection-list page Rank Math's 404 monitor and redirections newest first (at most 200 a page), and say plainly when a Yoast site has no such tables or the Rank Math module is switched off. The free plugin is unchanged: the operation count stays at 74.?page=sitehelm-connect (the console's first page is now ?page=sitehelm, Home).system-operation-schema will not describe it either; everything is on by default, including operations a later update adds. Stored in sitehelm_disabled_operations.wp-admin, and is itself re-restorable. The plan token never reaches the browser; it sits in a five-minute, per-user transient and is spent on the second click only.maxProperties is now both applied by the validator and declared on all six, two of them from limits their handlers were already enforcing without publishing. An over-large object is refused whole.content-block-update, content-meta-update, content-terms-assign, elementor-theme-conditions-set, menu-item-create, menu-item-update and menu-items-reorder accepted a list of any length, so their size was discovered by running out of time or memory rather than by being refused. One of them — the term identifiers inside content-terms-assign — sat one level down, inside an entry of another list. elementor-theme-conditions-set already enforced its limit in the handler and simply never published it; the schema now names the same constant. A new registry-wide test sweeps every input schema recursively and fails on the first array that declares no bound.type, enum, minimum, maxLength and the structural keywords, and silently ignored minLength, maximum, pattern, minItems and maxItems — 44 declarations across 22 files. A published schema is what an agent reads to learn what a site accepts, so a declared bound that is never checked is worse than an absent one: a well-behaved client stops checking for itself. maxItems was the only declared upper bound on array size anywhere in the catalog, so every batch operation accepted a list of any length and discovered the size only while walking it; an over-long array is now refused whole, before the walk. A new registry-wide test fails on the next keyword written into a schema that the validator does not apply.content-terms-assign writes one taxonomy at a time, but a failure reported the same two completed steps whichever write it happened on — so an operator whose second taxonomy failed was told that nothing had changed, when the first had already been applied. The rollback record was always complete; only the account of it was not.menu argument — menu-get, menu-item-create, menu-items-reorder, menu-location-assign — accepted a string of any length. A menu is a nav_menu term, and all three ways to name one resolve against columns bounded at 200 characters, so a longer string could never have matched one. It is now rejected by the argument schema, which says the bound, instead of by a not-found result after the search.media-upload and media-import described content that was fetched from a URL as though the caller had uploaded it, which reads as a refusal of some other request.xn-- form — the address is never converted, because the name checked and the name dialled would then be two different strings. That was learnable only from a refusal; it is now in the url field's own description.user-list and user-role-set. The people who can reach a site can now be read and, one account at a time, re-roled. The listing answers accounts by role or search term, newest registration first, and carries the role slugs this particular site has registered — a roster no fixed list could publish, since a store or a membership plugin adds its own. The write replaces one user's roles with a single registered slug. It refuses four things outright rather than letting a preview promise them: an unregistered slug, the acting user's own account, the last remaining administrator, and a multisite super admin — each of them a way to lock a site out of its own admin. Promoting someone to administrator is permitted and warned about, as is the collapse of a multi-role account down to the one role you sent; the snapshot keeps every role held beforehand, so a rollback restores the whole set rather than the first of them. Seeing the roster and changing it are two separate capabilities, and neither operation accepts the other's, so a client allowed to audit access cannot grant it. The target-bound edit check runs against the specific account in the preview, the apply, and the rollback. No password hash, reset key, or session token is reachable through either operation.comment-list, comment-status-set, and comment-reply. The comment queue can now be worked: listed by post, status, or search term newest first; one comment moved between approved, pending, spam, and trash; and a reply posted under a comment as the acting account. All three gate on the comment-moderation capability alone, so a moderator with no editing rights can use them and a capability meant for posts is never demanded alongside it. Nothing here deletes anything — spam and trash are reversible statuses on a row that stays where it is, every status write is snapshotted and rolls back, and the value that would perform a permanent deletion is not in the vocabulary at all. The status write goes through the same WordPress function the moderation screen does, so marking a comment as spam still records the prior status for WordPress's own unspam and still tells the anti-spam plugins what was decided. Two refusals exist because performing the write would produce a result with a hidden expiry date: a comment whose parent post is in the trash is refused with the real fix named, because WordPress owns that comment's status until the post returns; and a reply under a spam, trashed, or post-trashed parent is refused, because WordPress would silently reparent it to the top of the thread. Replying under a pending parent is allowed but warns that the parent is still awaiting moderation, so the reply does not sit invisible under an invisible comment. The listing defaults to approved plus pending together rather than the moderation queue alone — an empty queue reads as "nothing to do", which is the wrong answer to "what is on this post" — and spam and trash appear only when asked for by name. The commenter's IP address is never reported.content-seo-get and content-seo-set. One post's search-engine metadata can now be read and written on a site running either Yoast SEO or Rank Math, through one vocabulary that names neither: title, description, canonical, the four social fields, and the search-visibility directives. The answer carries a provider saying which store it came from, and that is the only place a plugin is named. If both plugins are installed Yoast serves the site, by a fixed precedence, so a write always lands in the store the read that planned it came from. The visibility directives are tri-state — null means the post says nothing and the plugin's own default decides, which is a different state from an explicit instruction to index — and Rank Math's inability to store an explicit *follow* is declared in the preview rather than discovered at verification. Writing a flag on Rank Math merges into the directive list it already stores, so noarchive or nosnippet set in the plugin's own screen survives a noindex change. The two social images are read-only, because both plugins keep an identifier and a cached URL that a partial write would leave disagreeing. A snapshot records which plugin it was taken from, so a rollback on a site whose SEO plugin has since changed is refused rather than replayed into a store the site no longer renders from.elementor-theme-template-list and elementor-theme-conditions-set. The header, footer, archive, search, 404, singular, and product templates a site has built can now be listed with the display conditions each one stores, and one template's conditions can be replaced as a whole rule — include/general for the whole site, exclude/singular/page/12 to carve out a page. The list is replaced whole rather than edited entry by entry, because the conditions on a template are one indivisible rule; that makes the write idempotent and the preview a complete statement of where the template will display afterwards. An empty list is legal and detaches the template without deleting it. A condition that does not parse refuses the whole request, so a half-applied rule is not reachable. The write discards Elementor's resolved condition map in the same step that stores the rule, so the front end stops serving the previous header immediately — without that, the stored value is correct and every re-read agrees while visitors still see the old one. Rolling back distinguishes a template that had no conditions from one that had an empty list, so a restore of a never-configured template removes the row rather than storing an empty one. Both operations omit templates the caller may not edit, and the write requires edit_theme_options — the capability Elementor puts on site-wide settings — as well as edit rights on the template itself.redirect-list, redirect-set, and redirect-delete. A retired URL can be pointed at its successor, so the traffic and the ranking the old address earned survive a rename, and a page that is simply gone can be marked 410 instead of answering 404 forever. redirect-set creates or replaces in one call — the target is the path itself, so sending the same redirect twice leaves one row. The visitor's query string is carried over by default, and a target may carry its own, which wins where the two name the same argument. Redirects are served on template_redirect, ahead of the front-end request, and never on an administration, cron, or REST request. A redirect that would send a visitor back to the path they asked for is refused both when it is written and when it would be served, because a rename months later can turn a good redirect into a loop. A site holds at most 500, and redirect-list reports the count beside the capacity so the bound is visible before a write refuses it. The whole table is one stored value, so rolling either write back restores the table as it stood at apply — any other redirect changed in the interval is reverted with it, and both operations say so in their descriptions.content-links-check, which reports the links in one content item and says which of this site's own links no longer lead anywhere. A rename leaves the site pointing at its old paths from inside its own content, and nobody sees those until a visitor clicks one. Every answer comes from this site's database: a link to another host is listed and left unchecked, because a content operation that makes outbound requests is a content operation that can be pointed anywhere. This site's own links are resolved the way a visitor's request resolves them — to a post, to the redirect that catches the path, or to nothing at all. A link a redirect already catches is reported as a redirect rather than hidden among the working ones, because a hop is a net, not a repair. brokenOnly trims the list to what needs fixing while the counts keep describing the whole page.content-blocks-get, which reads the block structure of a page instead of its text. Called with an identifier alone it returns the outline: every block, its address, its depth, the *names* of its attributes, and a short plain-text preview — enough to find the paragraph you meant without spending a context window on the ones you did not. Called with an address as well, it returns that one block in full: attribute values and inner markup, with its descendants still in outline form. The outline also reports whether the document can be rewritten a block at a time, so a client learns that before it plans a change rather than when the change is refused.content-block-update, which changes the attributes or the inner markup of one block and leaves every other block byte-identical. Because post_content is one column, a block edit is unavoidably a whole-document write — so the operation reproduces the document from its own parse and refuses, without writing, if that reproduction is not byte-identical to what is stored. It also requires the caller to name the block it expects at the address, since an index path cannot notice that the page was re-ordered since the outline was read, and it replaces inner markup only on a block with no inner blocks and a single chunk of markup, rather than guessing where text belongs among a block's children. Like every other write it previews first, snapshots the prior columns, verifies the result by re-reading it, and can be rolled back.elementor-composition-get, which says what an Elementor page contains at a size that does not grow with how much it contains. It returns the page's totals, a census of widget types and container types by how often each is used, and one entry per top-level band naming that band's identifier, how many elements sit inside it, and the widget types found anywhere beneath it — enough to decide which band to read in full, without reading all of them first. It also reports how many elements carry no stored identifier, which is exactly how much of the page no write can address; reading a full tree node by node left a client to notice that for itself. A page whose stored data cannot be read is refused here exactly as the full read refuses it, because a cheap digest of a damaged page is the one wrong answer a client would act on without hesitating.audit-list, system-environment and image-size-list each declared a capability and then left the asking entirely to the request path that calls them. That path does ask, so nothing was exposed; but a handler reachable only one way is a guarantee about today rather than a guarantee. All three now check for themselves, and audit-list checks before it looks at its storage, so a caller who may not read the change log no longer learns whether the log exists. No refusal names the capability it wanted, because a message that does is a way to enumerate what a credential is missing.media-resize, which brings an oversized image within a width and a height you name, so the size the site actually serves fits the sizes the theme renders. The original file is never overwritten and never deleted: the reduced image is written to a new file beside it, the media item is re-pointed at the new file, and the untouched original stays reachable through the same metadata WordPress uses for its own scaled uploads. A second reduction still reads the true original rather than the previous reduction, so detail is not thrown away twice. Rolling back points the item at the file and the metadata the snapshot recorded and then checks that it landed. An image already within the requested maximum is refused rather than re-saved, so a repeated request cannot reduce it twice.elementor-elements-update, which changes the settings of several elements on one Elementor page as a single reviewed change. One preview covers all of it, one save writes it, and one rollback reference undoes it. Every entry is checked against the page before anything is written, so an entry naming a setting the widget does not declare — or a layout element, or an element that is not there — refuses the whole request and leaves the page untouched, rather than landing the entries before it and stopping. A refusal names which change in the request caused it. Two entries naming the same element are refused rather than resolved by order.bridge/sitehelm-bridge.mjs is that subprocess: no dependencies, Node 18 or newer, and it forwards each message to the site's endpoint unchanged. The Connect screen now hands out a config that runs it, so the code on the operator's machine is the code that was reviewed and installed here rather than whatever a package registry serves at launch. The credential travels in the config's env block instead of on a command line, which every process on the machine can read. The public mcp-remote bridge is still offered beneath it, for a client running somewhere the plugin's files are not.system-operation-schema, a fifth system read that returns one named operation's full input and output schema on demand. An operation the caller cannot see does not surrender its schema: an unknown name and a hidden one are refused identically, so the answer cannot be used to map the site's surface area.Host header at all — WP-CLI, cron, an internal dispatch — is not treated as a mismatch, and neither is the www. spelling of the site's own domain.inputSchema and outputSchema. A dispatcher holding a dozen operations spent most of a client's context window on schemas for operations it would never call. Each entry keeps its usage example, which states the argument shape concretely, and the catalog names system-operation-schema as the way to fetch one full schema when it is actually needed. A client that read schemas straight from the catalog must now ask for them.retired_host, rather than advertising it as available and refusing it on use.mcp-remote bridge. Each carries the config in the shape that client actually reads — including the servers object VS Code wants rather than the mcpServers object everything else wants, and a config fragment for OpenClaw rather than a whole file that would overwrite settings unrelated to SiteHelm.Authorization header, or a revoked password — and one request separates those without involving a client at all.sitehelm/v1/mcp, speaking protocol version 2025-06-18. Handles initialize, notifications/initialized, ping, tools/list, and tools/call.VerificationFailed with completedSteps saying how far the operation got.edit_theme_options — the capability Elementor itself puts on the kit document. An update merges into the addressed entry, so setting a colour does not erase its title, and kit settings outside the palette are untouched. Typography setting names are validated by shape rather than against a fixed allowlist, so a control added by a newer Elementor is not refused; an entry's own _id stays unreachable, so a write cannot re-point a token that published pages already reference. Element search names the setting keys that matched, never the values they hold. Fields — ACF (5.9.0+) and Meta Box (5.3.0+), both optional. List groups, list fields, read a value, write a value. Values are normalised per field type before writing and verified by reading back; a field's formatted display value is never recorded as if it were the stored value, so a restore puts back what was really in the database. Diagnostics — confirm the connection and who is authenticated, report the WordPress and PHP versions with registered post types and taxonomies, and report the health of every optional integration as Active, Inactive, or VersionBlocked.media-import resolves and validates the host before connecting; private, loopback, link-local, and reserved ranges are refused; every redirect hop is re-validated and re-pinned; the resolved address is pinned so the connection cannot be re-pointed between the check and the fetch; the wire read is capped; and the refusal message is deliberately digit-free so it cannot be used as an SSRF oracle.$wpdb->prepare, and table names come from the installer rather than a hardcoded prefix.