{"id":301924,"date":"2026-07-01T15:23:58","date_gmt":"2026-07-01T15:23:58","guid":{"rendered":"https:\/\/wordpress.org\/plugins\/unseat-ai-connector\/"},"modified":"2026-08-25T16:12:28","modified_gmt":"2026-08-25T16:12:28","slug":"unseat-connector","status":"publish","type":"plugin","link":"https:\/\/nl.wordpress.org\/plugins\/unseat-connector\/","author":23483128,"comment_status":"closed","ping_status":"closed","template":"","meta":{"version":"2.16.1","stable_tag":"2.16.1","tested":"7.0.4","requires":"5.5","requires_php":"7.4","requires_plugins":null,"header_name":"Unseat Connector","header_author":"unseat.ai","header_description":"Connects WordPress to the unseat.ai content platform from Unseat AI Corporation. Registers SEO meta fields (Rank Math, Yoast, SEOPress) for the REST API, exposes a health-check status endpoint, serves llms.txt and IndexNow key files as plain text with a static-file fallback for hosts that strip .txt routes, injects an explicit AI-crawler allowlist into robots.txt, serves a live-generated cache-immune fallback sitemap, exposes an authenticated deep cache-flush endpoint, and emits per-post SEO meta tags (description, canonical, OpenGraph, Twitter Card) when no SEO plugin is active.","assets_banners_color":"b3a8c7","last_updated":"2026-08-25 16:12:28","external_support_url":"","external_repository_url":"","donate_link":"","header_plugin_uri":"https:\/\/unseat.ai\/","header_author_uri":"https:\/\/unseat.ai","rating":0,"author_block_rating":0,"active_installs":0,"downloads":150,"num_ratings":0,"support_threads":0,"support_threads_resolved":0,"author_block_count":0,"sections":["description","installation","faq","changelog"],"tags":{"2.16.1":{"tag":"2.16.1","author":"kglundin","date":"2026-08-25 16:12:28"},"2.7.4":{"tag":"2.7.4","author":"kglundin","date":"2026-07-01 15:23:29"}},"upgrade_notice":[],"ratings":[],"assets_icons":{"icon-128x128.png":{"filename":"icon-128x128.png","revision":3594150,"resolution":"128x128","location":"assets","locale":"","width":128,"height":128},"icon-256x256.png":{"filename":"icon-256x256.png","revision":3594150,"resolution":"256x256","location":"assets","locale":"","width":256,"height":256}},"assets_banners":{"banner-1544x500.png":{"filename":"banner-1544x500.png","revision":3594150,"resolution":"1544x500","location":"assets","locale":"","width":1544,"height":500},"banner-772x250.png":{"filename":"banner-772x250.png","revision":3594150,"resolution":"772x250","location":"assets","locale":"","width":772,"height":250}},"assets_blueprints":{},"all_blocks":[],"tagged_versions":["2.16.1","2.7.4"],"block_files":[],"assets_screenshots":[],"screenshots":[]},"plugin_section":[],"plugin_tags":[2353,244604,23853,1117,186],"plugin_category":[55],"plugin_contributors":[269741,269742],"plugin_business_model":[],"class_list":["post-301924","plugin","type-plugin","status-publish","hentry","plugin_tags-ai","plugin_tags-llms-txt","plugin_tags-rest-api","plugin_tags-schema","plugin_tags-seo","plugin_category-seo-and-marketing","plugin_contributors-kglundin","plugin_contributors-unseatai","plugin_committers-kglundin"],"banners":{"banner":"https:\/\/ps.w.org\/unseat-connector\/assets\/banner-772x250.png?rev=3594150","banner_2x":"https:\/\/ps.w.org\/unseat-connector\/assets\/banner-1544x500.png?rev=3594150","banner_rtl":false,"banner_2x_rtl":false},"icons":{"svg":false,"icon":"https:\/\/ps.w.org\/unseat-connector\/assets\/icon-128x128.png?rev=3594150","icon_2x":"https:\/\/ps.w.org\/unseat-connector\/assets\/icon-256x256.png?rev=3594150","generated":false},"screenshots":[],"raw_content":"<!--section=description-->\n<p>Unseat Connector is the official WordPress plugin for the <a href=\"https:\/\/unseat.ai\">unseat.ai<\/a> content platform from Unseat AI Corporation. It lets the platform publish and manage SEO-optimized content on your site, and exposes the signals AI crawlers (ChatGPT, Claude, Perplexity, Gemini) look for.<\/p>\n\n<p><strong>What it does:<\/strong><\/p>\n\n<ul>\n<li><strong>SEO Meta Fields<\/strong> \u2014 Registers Rank Math, Yoast SEO, and SEOPress meta fields (title, description, focus keyword, social tags) for REST API read\/write access. Works with or without an SEO plugin installed.<\/li>\n<li><strong>Site Health Endpoint<\/strong> \u2014 Provides a <code>\/wp-json\/seo-machine\/v1\/status<\/code> endpoint that reports WordPress version, active SEO plugin, permalink structure, and potential plugin conflicts.<\/li>\n<li><strong>llms.txt Serving<\/strong> \u2014 Serves your <code>llms.txt<\/code> and <code>llms-full.txt<\/code> pages as plain text at their canonical URLs, making your content index machine-readable for AI crawlers.<\/li>\n<li><strong>IndexNow Key<\/strong> \u2014 Serves your IndexNow verification key file as plain text.<\/li>\n<li><strong>AI-Crawler Allowlist<\/strong> \u2014 Injects an explicit <code>robots.txt<\/code> allowlist for GPTBot, ClaudeBot, PerplexityBot, CCBot, Google-Extended, Applebot, and other LLM crawlers with <code>Crawl-delay: 0<\/code>.<\/li>\n<li><strong>Fallback SEO Meta Tags<\/strong> \u2014 When no SEO plugin is active, emits <code>&lt;meta name=\"description\"&gt;<\/code>, canonical, OpenGraph, and Twitter Card tags from the connector's meta fields.<\/li>\n<li><strong>Optional GA4 Tag<\/strong> \u2014 Injects a Google Analytics 4 measurement ID via <code>wp_enqueue_script<\/code> when configured through the REST endpoint.<\/li>\n<\/ul>\n\n<p><strong>Requirements:<\/strong><\/p>\n\n<ul>\n<li>WordPress 5.5 or higher<\/li>\n<li>PHP 7.4 or higher<\/li>\n<li>An <a href=\"https:\/\/unseat.ai\">unseat.ai<\/a> account (for the publishing platform; the SEO meta and llms.txt serving features work without one)<\/li>\n<\/ul>\n\n<h3>External services<\/h3>\n\n<p>This plugin can optionally load <strong>Google Analytics 4<\/strong> (gtag.js) from Google. This integration is <strong>off by default<\/strong> and is only activated after a site administrator sets a GA4 Measurement ID (format <code>G-XXXXXXXX<\/code>) through the plugin's REST endpoint (<code>POST \/wp-json\/seo-machine\/v1\/ga4<\/code>). No external service is contacted until that ID is configured.<\/p>\n\n<p>What it is used for: when a Measurement ID is configured, the plugin enqueues Google's analytics script from <code>https:\/\/www.googletagmanager.com\/gtag\/js<\/code> so the site owner can measure website traffic.<\/p>\n\n<p>What data is sent and when: while a Measurement ID is configured, the script loads on front-end page views for visitors who are not logged-in administrators (logged-in administrators are excluded to avoid inflating analytics). On each such page view the visitor's browser sends the standard Google Analytics data directly to Google \u2014 for example the page URL, referrer, IP address, and device\/browser information. No data is sent when no Measurement ID is configured, and the plugin itself does not collect, store, or transmit any of this data to unseat.ai or to any other party.<\/p>\n\n<p>This service is provided by Google. By configuring a GA4 Measurement ID you are choosing to use Google Analytics on your site; please review Google's terms and privacy policy:<\/p>\n\n<ul>\n<li>Google Analytics Terms of Service: https:\/\/marketingplatform.google.com\/about\/analytics\/terms\/us\/<\/li>\n<li>Google Privacy Policy: https:\/\/policies.google.com\/privacy<\/li>\n<li>How Google uses information from sites or apps that use its services: https:\/\/policies.google.com\/technologies\/partner-sites<\/li>\n<\/ul>\n\n<p>No other external services are contacted by this plugin. All SEO meta, llms.txt, IndexNow, robots.txt, sitemap, status, and cache-flush features operate entirely on your own server.<\/p>\n\n<!--section=installation-->\n<ol>\n<li>Install directly from the WordPress plugin directory, or upload the plugin zip via Plugins &gt; Add New &gt; Upload Plugin.<\/li>\n<li>Activate the plugin.<\/li>\n<li>Verify the connector is working by visiting <code>https:\/\/yoursite.com\/wp-json\/seo-machine\/v1\/status<\/code> (requires authentication).<\/li>\n<\/ol>\n\n<p>The unseat.ai platform will automatically detect the connector during onboarding.<\/p>\n\n<!--section=faq-->\n<dl>\n<dt id=\"do%20i%20need%20rank%20math%2C%20yoast%2C%20or%20seopress%20installed%3F\"><h3>Do I need Rank Math, Yoast, or SEOPress installed?<\/h3><\/dt>\n<dd><p>No. The connector registers SEO meta fields that work with or without an SEO plugin, and emits its own meta\/OpenGraph\/Twitter Card tags when none is active.<\/p><\/dd>\n<dt id=\"what%20is%20llms.txt%3F\"><h3>What is llms.txt?<\/h3><\/dt>\n<dd><p>llms.txt is a machine-readable index of your site content, similar to robots.txt but designed for AI language models. It helps AI search engines discover and cite your content.<\/p><\/dd>\n<dt id=\"does%20this%20plugin%20collect%20any%20data%3F\"><h3>Does this plugin collect any data?<\/h3><\/dt>\n<dd><p>The plugin itself does not collect or store any visitor data \u2014 it adds REST API endpoints to your existing WordPress installation, and all of that data stays on your server. The unseat.ai platform connects to these endpoints using your WordPress application password.<\/p>\n\n<p>The one exception is the <strong>optional<\/strong> Google Analytics 4 integration: if an administrator configures a GA4 Measurement ID, the plugin loads Google's analytics script so visitor analytics data is sent from the visitor's browser to Google. This is off until you configure it. See the <strong>External services<\/strong> section above for exactly what is sent and links to Google's terms and privacy policy.<\/p><\/dd>\n<dt id=\"can%20i%20use%20this%20without%20an%20unseat.ai%20account%3F\"><h3>Can I use this without an unseat.ai account?<\/h3><\/dt>\n<dd><p>Yes. The SEO meta field registration, llms.txt serving, and robots.txt allowlist all work independently. The status endpoint is primarily useful for the unseat.ai platform.<\/p><\/dd>\n\n<\/dl>\n\n<!--section=changelog-->\n<h4>2.16.1<\/h4>\n\n<ul>\n<li>Fixed: on a site's FRONT PAGE the founder's entity links published nowhere. Rank Math anchors its per-page author byline at the page canonical, which on the front page is the home URL \u2014 so the byline looked like the site's own identity node and, when the founder had written the homepage, carried a matching name. The connector treated it as the site's Person, merged the links onto it and skipped creating the real one; Rank Math then deleted that byline as unreferenced and the links went with it. On a site running Rank Math the front page now publishes the same identity node every other page already did.<\/li>\n<li>Also fixed: where that byline sorted ahead of the real identity node, the Organization's \"founder\" pointed at the byline \u2014 a reference to a node that was about to be deleted. It now points at the identity node.<\/li>\n<li>On a site with NO SEO plugin, where the connector rewrites the page head directly, it cannot add an identity node beside a byline it declines to use; on such a site the front page publishes no person links rather than writing them onto the byline. No current site is affected \u2014 the one site on that path anchors its identity elsewhere and is unchanged.<\/li>\n<li>No change on any other page type, and none to what is stored or to sites whose knowledge graph is a Person (that node is matched by its own anchor, unchanged).<\/li>\n<\/ul>\n\n<h4>2.16.0<\/h4>\n\n<ul>\n<li>Entity data now also publishes on a site where Rank Math is INSTALLED but renders no schema graph of its own \u2014 a switched-off schema module, a theme that emits the identity nodes instead, or a page type Rank Math declines to describe. Previously the mere presence of Rank Math stood the head rewrite down in favour of a filter that never fired, so neither path published anything.<\/li>\n<li>On any request where Rank Math does render its graph, output is unchanged: the filter path still owns that request, and the head rewrite still never runs alongside it.<\/li>\n<\/ul>\n\n<h4>2.15.0<\/h4>\n\n<ul>\n<li>Company and founder entity data now publish on sites that do NOT run Rank Math \u2014 Yoast, SEOPress, AIOSEO, or no SEO plugin at all. The connector rewrites the JSON-LD already in the page head rather than adding a competing block.<\/li>\n<li>The site's own person is now recognised by the founder's name as well as by the <code>#person<\/code> anchor, so Yoast's <code>&lt;home&gt;#\/schema\/Person\/{{ID}}<\/code> is extended rather than duplicated.<\/li>\n<li>The company node is created when a page claims none, which needs the company name \u2014 so this requires platform build 2.15.0 or newer on both sides. An older platform's push is reported as unpublished rather than recorded as a success it did not get.<\/li>\n<li>Rank Math sites are unchanged: they keep the existing filter path and the head rewrite does not run on them.<\/li>\n<li>The SEO-meta fallback comment now reports the real plugin version instead of a hard-coded <code>v2.5.3<\/code>.<\/li>\n<\/ul>\n\n<h4>2.14.0<\/h4>\n\n<ul>\n<li>The <code>seo-machine\/v1\/entity<\/code> endpoint now also stores the founder's name and job title, and the <code>rank_math\/json_ld<\/code> filter builds the site's Person node from them when \u2014 and only when \u2014 the site does not already have one.<\/li>\n<li>This is the one case where the plugin creates a node rather than merging into one, and it is not a reversal of the rule above. Rank Math's knowledge graph is either an Organization or a Person, never both, so a company site emits no <code>#person<\/code> node at all and there is nothing to merge into and nothing to contradict. Where the site DOES emit its own Person node, this creates nothing and merges as before.<\/li>\n<li>A name is required. <code>sameAs<\/code> links hung on a nameless node are an identity claim about nobody, so with no founder name the person links stay unpublished and the site is left exactly as it was.<\/li>\n<li>The new node is anchored at <code>&lt;home&gt;#person<\/code>, carries <code>worksFor<\/code> pointing at the Organization node the page actually emits, and the Organization node gets the reciprocal <code>founder<\/code> edge \u2014 but only if it does not already declare one. A <code>founder<\/code> the site owner has set is the site owner's claim, not this plugin's to overwrite.<\/li>\n<li>Requires platform build 2.14.0 or newer on both sides: the endpoint echoes the identity back, and a platform that does not receive it reports the push as unpublished rather than recording a success it did not get.<\/li>\n<\/ul>\n\n<h4>2.13.0<\/h4>\n\n<ul>\n<li>New <code>seo-machine\/v1\/entity<\/code> endpoint (administrator-only) stores the two <code>sameAs<\/code> lists \u2014 the organization's authority profiles and the founder's \u2014 and a <code>rank_math\/json_ld<\/code> filter merges them into the Organization and Person nodes Rank Math ALREADY emits, on every page of the site.<\/li>\n<li>It merges and it never creates. If Rank Math emits no knowledge-graph block, this plugin emits nothing and the links stay unpublished. That is deliberate: a second Organization block claiming to describe the same company is an entity conflict, and search engines resolve a contradiction worse than they resolve a gap. Silence is recoverable; a competing claim is not.<\/li>\n<li>The nodes are identified by the <code>@id<\/code> suffix Rank Math anchors them at (<code>#organization<\/code> \/ <code>#person<\/code>). <code>@type<\/code> is only consulted for a node carrying no <code>@id<\/code> at all: it is whatever the site owner picked in the setup wizard, and the LocalBusiness subtype list is effectively unbounded, so matching on it alone would miss every site that chose a specific one. An <code>@id<\/code> that does not match is a refusal, never a fall-through \u2014 a real page also carries <code>&lt;post-url&gt;#author<\/code> (a Person named after the WordPress login account) and <code>#schema-NNNNNN<\/code> duplicates of the Organization node, and writing into those would assert one identity from many conflicting nodes.<\/li>\n<li>Existing <code>sameAs<\/code> entries come first and keep their relative order; ours are appended. De-duplication is case-insensitive and ignores a trailing slash, and it runs across the WHOLE list rather than only over what we add \u2014 otherwise appending <code>https:\/\/x.com\/acme<\/code> beside an existing <code>https:\/\/X.com\/acme\/<\/code> would publish the same profile twice. Two consequences worth stating plainly rather than discovering later: an existing entry has surrounding whitespace trimmed off it, and if the site's own list already carried the same URL twice under different spellings, the second copy is dropped. Nothing else about an existing entry is rewritten, and none are reordered.<\/li>\n<li>Stored URLs are validated on the way in, not on the way out: anything that is not <code>http<\/code>\/<code>https<\/code> is dropped, and the list is capped. A stored value is emitted into the <code>&lt;head&gt;<\/code> of every page, so the endpoint is the only place that check belongs.<\/li>\n<li>The endpoint echoes back exactly what it stored, and the platform treats a mismatch as a failed push rather than a success. <code>ok: true<\/code> on its own does not prove the links landed \u2014 a redirect can downgrade the POST to a GET that returns the site's OLD list, and the sanitizer drops silently \u2014 and a marker written on that flag would freeze the site's entity data permanently.<\/li>\n<li><code>seo-machine\/v1\/status<\/code> now reports how many entity links the site is holding, so the platform can confirm a push landed without re-sending it \u2014 and can tell \"we never sent them\" apart from \"we sent them and there is no Rank Math block to merge them into\".<\/li>\n<\/ul>\n\n<h4>2.12.0<\/h4>\n\n<ul>\n<li>The <code>seo-machine\/v1\/robots<\/code> diagnostic no longer speaks a single English paragraph chosen by a branch chain. It returns <code>findings<\/code> \u2014 a list, worst first, where each entry carries the <code>evidence<\/code> it read and a <code>message<\/code> that may assert nothing outside it. Findings COMPOSE: none suppresses another, and <code>severity<\/code> sorts the list rather than steering control flow. <code>cache_suspected<\/code> and <code>reason<\/code> are gone.<\/li>\n<li>This replaces the design, not a bug in it. Twelve review rounds each found a fresh defect INSIDE the previous round's fix, and all twelve were one class: a claim that outran its reads. That was inevitable \u2014 the explanation was thirteen hand-written branches over eight mostly tri-state facts, a state space in the hundreds, where branch ORDER decided which sentence got spoken and every new branch silently pre-empted an older one. A structure that cannot be reviewed to correctness will keep producing correct-looking sentences about faults it did not observe, however many rounds it survives.<\/li>\n<li>A whole-site block is now read correctly when this plugin's own output is merged into it. RFC 9309 merges records naming the same user-agent, and this plugin injects <code>Allow: \/?unseat_sitemap=<\/code> and <code>Allow: \/wp-admin\/admin-ajax.php<\/code> into the <code>*<\/code> group. A maintenance plugin emitting <code>User-agent: *<\/code> \/ <code>Disallow: \/<\/code> therefore lands in the SAME group as those Allows \u2014 and the predicate, which bailed on any non-empty <code>Allow:<\/code>, concluded the site was not blocked and returned <code>connector_governs: true<\/code> for a site no crawler could enter. The plugin's own output was masking a whole-site ban from the plugin's own detector. Only an <code>Allow:<\/code> of the ROOT re-opens a <code>Disallow: \/<\/code>; <code>Allow: \/blog<\/code> re-opens <code>\/blog<\/code> and leaves the rest of the domain dead, per longest-match.<\/li>\n<li><code>serving_wp_output<\/code> replaces <code>cache_suspected<\/code>. It is a byte comparison of what the live URL returns against what WordPress generates. The old field was derived from the <code># Injected by unseat-connector<\/code> marker, which this plugin appends AFTER whatever blob won the filter \u2014 so the marker can sit on output we did not produce, and any verdict drawn from it is fabricated evidence.<\/li>\n<li>When the live <code>\/robots.txt<\/code> does not return a 2xx, every served-side fact is set to null at the source rather than guarded at each consumer. A read that failed is not a fact, and a diagnostic that has to remember to check <code>served_code<\/code> before each of nine other fields will eventually forget once.<\/li>\n<li>No finding gives remediation that depends on a fact it did not read. A physical <code>robots.txt<\/code> on disk is reported as present and the operator is told to read it and compare it against <code>rendered<\/code> \u2014 not to delete it, because if WordPress's own output is currently wrong that file may be the only thing keeping the site crawlable. A crawler group that escapes <code>*<\/code> is named and the rules are to be added INSIDE it \u2014 not to delete the group, because all this endpoint read is that the group fails to block query-string URLs, never what else it carries.<\/li>\n<li><code>connector_governs<\/code> is now a projection of the findings and is false whenever any critical one fires. Two fields computed separately from the same reads is how this endpoint once shipped <code>connector_governs: true<\/code> in the same payload as a sentence describing a de-indexed site.<\/li>\n<li>A SEARCH ENGINE banned from the entire domain by name is now a critical finding. It used to produce NOTHING. The coverage pass exempts any named group that blocks its own bot \u2014 correct for <code>User-agent: AhrefsBot<\/code> \/ <code>Disallow: \/<\/code>, which is banned harder than our rules and where \"delete that group\" would unblock a scraper somebody banned on purpose \u2014 but it applied the same reasoning to Googlebot, where being banned IS the emergency and not the reason to stop looking. A file carrying <code>User-agent: Googlebot<\/code> \/ <code>Disallow: \/<\/code> returned an empty <code>findings<\/code> list and <code>connector_governs: true<\/code>, on a site Google cannot crawl one URL of. It is reported and never remediated: this endpoint has read that the ban is there, not why it is there, and a staging box bans Googlebot on purpose.<\/li>\n<li><code>Disallow: **<\/code> is read as the whole-site block it is. Both recognisers match a literal set of spellings and the collapse step returned an all-star path untouched, so <code>**<\/code> was in neither set: a site with every URL blocked was reported as one where \"query-string URLs are NOT blocked\" \u2014 the exact opposite of the truth \u2014 while <code>Disallow: *<\/code>, the same directive, fired two criticals. A banned Googlebot spelled that way got an escape lecture telling the operator to add query rules to the group that had already shut Google out of his domain. All-star paths now collapse to the one canonical spelling. (An EMPTY path is not an all-star path: a bare <code>Allow:<\/code> is an explicit no-op in RFC 9309, and collapsing it to <code>allow: *<\/code> would turn a line that re-opens nothing into one that re-opens everything.)<\/li>\n<li>The whole-site <code>*<\/code> critical reads the paths its group re-opens, not just the fact of the ban. <code>Disallow: \/<\/code> with <code>Allow: \/blog\/<\/code> shuts every crawler out of everything EXCEPT <code>\/blog\/<\/code> \u2014 and the finding said the domain was \"removed from search results\" and to fix the root <code>Disallow:<\/code> \"before anything else\", which would have thrown the entire site open to crawlers somebody fenced into one subtree on purpose. Same defect as the named-bot ban below, one group over, and <code>*<\/code> is the commoner place to write a fence. The predicate behind it stays deliberately LOOSE \u2014 the strict one is what a competing plugin's merged <code>Disallow: \/<\/code> hid behind the connector's own injected <code>Allow:<\/code> lines \u2014 so what had to learn to read the paths was the prose, not the test. On a connector site that list is usually our own two <code>Allow:<\/code> lines, and naming them is exactly right: they are the only URLs a crawler can still reach.<\/li>\n<li>A de-indexed WordPress site is no longer told its robots.txt is a deliberate fence it should ignore. \"Is this a fence or a shut-out?\" was answered by asking whether the group re-opened ANY path \u2014 but WordPress core's robots.txt default always carries <code>Allow: \/wp-admin\/admin-ajax.php<\/code>, and this plugin injects two <code>Allow:<\/code> lines of its own, so on a real site that answer was always YES. The honest branch was unreachable: a site with <code>Disallow: \/<\/code> and every content URL blocked was reported as a fence around <code>admin-ajax.php<\/code>, told \"if that fence is deliberate, you should ignore this\", and expressly forbidden to delete the root <code>Disallow:<\/code> \u2014 the one repair. The same masking bug as the merged-<code>Allow:<\/code> defect above, one layer out: the boilerplate that once hid a ban from the PREDICATE went on to hide it from the OPERATOR. A fence is now judged on the paths somebody CHOSE \u2014 boilerplate subtracted \u2014 for the <code>*<\/code> group and for named bots alike, so a hand-pasted <code>User-agent: Googlebot<\/code> \/ <code>Disallow: \/<\/code> \/ <code>Allow: \/wp-admin\/admin-ajax.php<\/code> reads as the total ban it is. The boilerplate list is fixed rather than derived from our own <code>rules()<\/code>, because <code>rules()<\/code> is filterable and a third-party filter that widened it would SUPPRESS a real fence; a test fails if our own directives ever drift out of it.<\/li>\n<li><p><code>Disallow: \/*$<\/code> is read as the whole-site block it is. A <code>*<\/code> immediately before the end-anchor makes the anchor vacuous \u2014 <code>*<\/code> already absorbs everything up to the end \u2014 so <code>\/*$<\/code> matches every URL on the domain, but the collapse step left any <code>$<\/code>-terminated path alone and both recognisers missed it, reporting a fully blocked site as one whose query-string URLs were not blocked. A <code>$<\/code> that does NOT follow a star is a real anchor and still survives untouched: <code>\/$<\/code> is the homepage and nothing else, and <code>\/*.php$<\/code> is not a whole-site block.<\/p><\/li>\n<li><p>A search engine fenced into a few paths is no longer reported as one shut out of the site. <code>Disallow: \/<\/code> plus <code>Allow: \/blog\/<\/code> is not a ban: under longest-match that crawler reaches <code>\/blog\/<\/code> and nothing else. The whole-site-ban critical fired on it anyway \u2014 told the operator the crawler \"will not crawl a single URL on this domain\", which is false and checkable against his own GSC, and then told him to remove the whole-site <code>Disallow:<\/code>, which would have thrown the ENTIRE site open to a bot he deliberately fenced into one subtree. A critical whose remediation is the harm. The two cases are now two findings: a total ban (a root <code>Disallow:<\/code> and no <code>Allow:<\/code> at all) stays critical, and a fence is reported as a fence, names the paths it re-opens, and orders nothing.<\/p><\/li>\n<li><p>Two sentences no longer claim to be exhaustive about evidence they never read. The whole-site <code>*<\/code> critical said \"every crawler that obeys robots.txt is excluded\" \u2014 false for any crawler with a group of its own, which is the entire bug this endpoint exists for. And the served-differs-from-generated major named a CDN cache and a physical file as \"the two things that do this\", so an operator behind an nginx <code>location = \/robots.txt<\/code> rewrite purged a cache that was not the cause and hunted a file that was not there.<\/p><\/li>\n<li><p>A site with \"Discourage search engines\" ticked is no longer told to hunt a plugin that does not exist. Both injecting filters no-op when search-engine visibility is off, so the connector's rules are absent BY DESIGN \u2014 and the report shipped two majors blaming a competing <code>robots_txt<\/code> callback for discarding them, ranking the one sentence that told the truth below both. The cause is now read from <code>blog_public<\/code> rather than assumed, and both findings declare it. This is not an edge case: it is every staging clone promoted to production with the checkbox still ticked.<\/p><\/li>\n<li>A UTF-8 BOM no longer hides a whole-site block. <code>trim()<\/code> does not strip a BOM, so <code>\ufeffUser-agent: *<\/code> failed to parse as a user-agent line and every rule in that group was dropped as belonging to no group. A <code>robots.txt<\/code> saved with a BOM by a Windows editor and carrying <code>Disallow: \/<\/code> was reported as a file that merely fails to block query strings \u2014 the opposite of the truth, with the whole-site critical silent.<\/li>\n<li><code>serving_wp_output<\/code> compares content, not raw bytes. A host that rewrites line endings changes no directive, and the byte compare called that \"the live URL is not WordPress's output\" and sent the operator after a CDN that was serving exactly what WordPress generated.<\/li>\n<li>The normalize\/collapse\/merge pipeline lives in one function now that two readers need it. It had already drifted once: the whole-site reader skipped the trailing-star collapse, so <code>Disallow: \/**<\/code> was seen by one reader and missed by the other in the same response.<\/li>\n<li>A banned Googlebot no longer gets a critical and a major that contradict each other. Two readers held two different notions of \"totally banned\" \u2014 one had learned that boilerplate <code>Allow:<\/code> lines do not count, the other had not \u2014 so <code>User-agent: Googlebot<\/code> \/ <code>Disallow: \/<\/code> \/ <code>Allow: \/wp-admin\/admin-ajax.php<\/code> shipped a critical saying Google \"will not crawl a single page on this domain\" NEXT TO a major saying that group \"does not block query-string URLs\" and to add query rules inside it. The major is false \u2014 Google is already blocked from every URL, query-string or not \u2014 and following it made two findings disappear while Google stayed banned. Both readers now share one predicate, so the total-ban exemption and the total-ban critical can never disagree about the same group again.<\/li>\n<\/ul>\n\n<h4>2.11.0<\/h4>\n\n<ul>\n<li>Fixed the Rank Math robots.txt check, which read an option name that has never existed. Rank Math stores its custom robots.txt in <code>rank-math-options-general<\/code> (hyphens); this plugin read <code>rank_math_options_general<\/code> (underscores), following the spelling of every other Rank Math name it touches \u2014 the post meta and <code>rank_math_modules<\/code> really are underscored. <code>get_option()<\/code> on an absent name returns false, so the check reported \"no Rank Math override\" on the one site that had one, and the release below silently did nothing. The name now lives in a single constant, read and written from one place.<\/li>\n<li>This was the whole of the revheat.com bug. Rank Math registers the <code>robots_txt<\/code> filter only when its stored content is non-empty, and then returns that content and discards everyone else's. A stale blob pasted into its editor had been replacing this plugin's rules for months, re-declaring a <code>User-agent: Googlebot<\/code> group that held only <code>Disallow: \/wp-admin\/<\/code> \u2014 and robots.txt has no inheritance, so Googlebot obeyed that group, ignored <code>*<\/code>, and never saw the <code>Disallow: \/*?<\/code> that would have kept it off ~60 junk query-string clones of the homepage.<\/li>\n<li>The release route (<code>POST seo-machine\/v1\/robots<\/code> with <code>release_override=true<\/code>) now re-reads the option after clearing it and reports <code>released<\/code> from what the database says, not from what it tried to do. A write swallowed by a <code>pre_update_option_*<\/code> short-circuit or a read-only database previously reported success over an unchanged site.<\/li>\n<li><code>seo-machine\/v1\/robots<\/code> now observes the query-string rules directly (<code>query_rules_in_rendered<\/code> \/ <code>query_rules_in_served<\/code>) instead of inferring them from the <code># Injected by unseat-connector<\/code> marker. The marker is emitted by the AI-allowlist filter alone; the <code>Disallow: \/*?<\/code> rule comes from a different filter that carries no marker and bails silently on plain permalinks or a first group with no <code>User-agent:<\/code> line. A site could therefore carry the marker, report <code>connector_governs: true<\/code>, and still be leaking every query-string URL to Googlebot.<\/li>\n<li>That check is anchored to GROUPS, not to the document. robots.txt has no inheritance: a crawler with its own <code>User-agent:<\/code> group obeys that group and ignores <code>*<\/code> entirely. A file can carry <code>Disallow: \/*?<\/code> under <code>User-agent: *<\/code> and also declare a <code>User-agent: Googlebot<\/code> group holding only <code>Disallow: \/wp-admin\/<\/code> \u2014 and Googlebot will crawl every query-string URL. That is precisely the file revheat.com served, so \"the rule appears somewhere in the document\" is not a check, it is the bug wearing a tick. The new <code>query_rules_shadowed_by_served<\/code> names each crawler that has walked out of the group the rules are in and can still reach a query-string URL.<\/li>\n<li>Directives are compared per RFC 9309 \u2014 names case-insensitive, whitespace around the colon insignificant, paths case-sensitive. Records naming the same user-agent are merged, as Google's parser merges them. An exact string match read <code>disallow: \/*?<\/code> as absent and told the operator \"query-string URLs are NOT blocked\" about a file that blocks them perfectly well, which is the wrong answer to give about the one input this endpoint exists to read: a robots.txt somebody else wrote.<\/li>\n<li>A group is only an escape if a query-string URL is still REACHABLE through it \u2014 never merely because it does not repeat this plugin's rules verbatim. A group carrying <code>Disallow: \/*?<\/code> without the <code>Allow: \/?unseat_sitemap=<\/code> exception is stricter than <code>*<\/code>, not looser; so is one blocked from the whole site (<code>User-agent: AhrefsBot<\/code> \/ <code>Disallow: \/<\/code>). Neither is reported. And where a group IS reported, the remediation is to add the query-string rules inside it \u2014 never to delete it. This endpoint reads one thing about a group, that a query-string URL can still be reached through it; it does not read what else the group carries. \"Delete it, <code>*<\/code> already covers them\" is a coverage claim nothing here checked, and against a <code>User-agent: Googlebot<\/code> group holding <code>Disallow: \/staging\/<\/code> it hands Googlebot the staging site.<\/li>\n<li>Of the groups that genuinely do escape, only a crawler that actually INDEXES flips the verdict. A bot that ignores <code>*<\/code> but files nothing into a search index is named in <code>query_rules_shadowed_by_*<\/code> as evidence and does not fail the site \u2014 a diagnostic that reddens a healthy install teaches operators to ignore it.<\/li>\n<li>Every <code>reason<\/code> is written from the reads in the same response. The live URL's HTTP status is now read, so a host that 404s <code>\/robots.txt<\/code> is no longer told to purge a CDN; a physical robots.txt on disk is named even when the verdict is healthy, because a file the web server may be serving directly is a frozen copy that no future release can update; and no branch asserts what the live URL returns on a request where fetching it failed.<\/li>\n<li>The sentence that endpoint speaks is now a pure function of the reads that produced it, so it can be tested without a WordPress install, a live HTTP fetch or a filter replay. It could not be before, and that is where every defect above was found \u2014 a diagnostic whose explanation is untestable will happily explain a fault it did not observe.<\/li>\n<li>The release route now verifies its BACKUP before it clears anything, and refuses to clear an override it could not back up. It re-read the option it cleared but not the option it saved \u2014 the write whose failure cannot be undone. A host or security plugin that filters option writes by name could swallow the backup while letting Rank Math's own option through, and the route would report <code>released: true<\/code> over a customer's hand-written robots.txt that no longer existed anywhere. <code>backup_option<\/code> is also named only when a backup provably exists; it used to be asserted on every call, including calls that wrote none.<\/li>\n<li>The verdict now reads the live URL's HTTP STATUS, not just its body. A non-2xx response still has a body, and that body can be a perfectly correct robots.txt \u2014 a plugin fataling on <code>shutdown<\/code> after the file has already flushed returns HTTP 500 with the right content on the wire. Every content check passed and the verdict came back <code>connector_governs: true<\/code>, in the same payload as a <code>reason<\/code> reading \"what came back is an error page, not the robots.txt crawlers read\". A response we could not make sense of is a read we did not make.<\/li>\n<li>On a subdirectory install the endpoint no longer invents a fault. There, the rules are scoped (<code>Disallow: \/blog\/*?<\/code>), and a <code>*<\/code> group carrying the site-wide <code>Disallow: \/*?<\/code> blocks strictly more while our <code>Allow:<\/code> exception survives untouched \u2014 a correct site. It was told its <code>Allow: \/?unseat_sitemap=<\/code> was missing, that its fallback sitemap was unreachable, and was pointed at a URL that install does not use. The endpoint now names the rules it actually observed to be absent rather than guessing which one it was.<\/li>\n<li>A group that blocks query-string URLs in a DIFFERENT SPELLING is no longer called an escape. <code>Disallow: \/*?*<\/code> blocks exactly what <code>Disallow: \/*?<\/code> blocks \u2014 a trailing <code>*<\/code> matches the empty string \u2014 and it is the commoner spelling in the wild. A <code>User-agent: Googlebot<\/code> group carrying it was reported as escaping, told the operator \"query-string URLs are wide open\" for a crawler that could not reach a single one, and returned <code>connector_governs: false<\/code> on a correct file forever. A trailing wildcard run is now collapsed before comparison. Only a trailing run: <code>Disallow: \/*?*=*<\/code> needs a <code>=<\/code> and is genuinely narrower than our rule, so it is still reported.<\/li>\n<li>The rules named in <code>query_rules_missing_from_star_rendered<\/code> are printed in the spelling this plugin emits, matching <code>query_rules<\/code>; they were being emitted lower-cased, so the two fields could not be diffed against each other. The field is null, not an empty list, when the filter replay threw and the <code>*<\/code> group was never read \u2014 an empty list reads as a clean bill of health on a request that looked at nothing.<\/li>\n<li>The release route explains every failure it can have, not just one. A backup that verified while the CLEAR write was rejected \u2014 a settings-lock plugin guarding Rank Math's option specifically \u2014 returned <code>released: false<\/code> with nothing to explain it, and the operator was left reading a <code>reason<\/code> about a competing robots.txt filter, which is true of the site but wrong about that call. <code>release_blocked<\/code> is now non-null in both failure states and says plainly which write was refused.<\/li>\n<li>The backup verification now bypasses the object cache. <code>update_option()<\/code> seeds the cache with the value it just tried to store, so re-reading it handed back our own array out of memory and passed the check without the database ever being consulted \u2014 a verification that verified itself.<\/li>\n<li>The Rank Math unhook matches a statically-registered callback too, not only an object instance \u2014 in all three callable spellings, including the <code>'RankMath\\Foo::robots_txt'<\/code> string. It exists to survive a priority change without becoming a silent no-op; testing only for an object left it one refactor away from exactly that.<\/li>\n<li>A <code>User-agent: *<\/code> group that disallows the ENTIRE site (<code>Disallow: \/<\/code>) is now read, named, and reported before anything else. Nothing in the endpoint had ever looked for it: the query-rule checks read the same file, found <code>Disallow: \/*?<\/code> absent, and printed \"Query-string URLs are NOT blocked\" \u2014 the exact opposite of the truth \u2014 about a site where every URL is blocked to every crawler, without once mentioning the disallow that was removing the domain from search results. A staging robots.txt pasted into an SEO plugin's editor is precisely how a live site arrives in that state, which makes it the single worst input this endpoint can be handed. It is now checked on both the rendered and the served copy, and it names its own cause: a stale edge copy, a competing callback, or a file on disk. It is measured straight off the body rather than off the query-rule pass, so a plain-permalink site \u2014 where this plugin emits no query rules and the coverage pass never ran \u2014 is no longer exempt from the check, which is how one could previously be handed a clean bill of health while de-indexed.<\/li>\n<li>The equivalent-spelling collapse now applies to <code>Allow:<\/code> as well as <code>Disallow:<\/code>. <code>Allow: \/?unseat_sitemap=*<\/code> is this plugin's own rule \u2014 a trailing <code>*<\/code> matches the empty string \u2014 and it was being reported missing, which told a correct site that its <code>Allow:<\/code> exception was gone and its fallback sitemap unreachable. The same fabrication as the <code>Disallow: \/*?*<\/code> case above, aimed at our own output instead of a stranger's.<\/li>\n<li><code>Disallow: *?<\/code> is recognised as the same rule as <code>Disallow: \/*?<\/code> \u2014 a leading <code>*<\/code> matches the leading slash. A <code>User-agent: Googlebot<\/code> group carrying it was called an escape and the operator told query-string URLs were wide open for a crawler that could not reach one.<\/li>\n<li><code>release_blocked<\/code> no longer tells the operator to disregard <code>filters<\/code> when the clear write is refused. The override IS still in place and still winning in that case \u2014 that is why they called the route \u2014 so the sentence contradicted the <code>reason<\/code> shipping beside it in the same payload.<\/li>\n<li>The whole-site-ban verdict no longer tells an operator to delete the file that is keeping their site in  &hellip;<\/li>\n<\/ul>","raw_excerpt":"Connect WordPress to unseat.ai. Registers SEO meta fields for the REST API, adds a site health endpoint, and serves llms.txt for AI discovery.","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/nl.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin\/301924","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/nl.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin"}],"about":[{"href":"https:\/\/nl.wordpress.org\/plugins\/wp-json\/wp\/v2\/types\/plugin"}],"replies":[{"embeddable":true,"href":"https:\/\/nl.wordpress.org\/plugins\/wp-json\/wp\/v2\/comments?post=301924"}],"author":[{"embeddable":true,"href":"https:\/\/nl.wordpress.org\/plugins\/wp-json\/wporg\/v1\/users\/kglundin"}],"wp:attachment":[{"href":"https:\/\/nl.wordpress.org\/plugins\/wp-json\/wp\/v2\/media?parent=301924"}],"wp:term":[{"taxonomy":"plugin_section","embeddable":true,"href":"https:\/\/nl.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_section?post=301924"},{"taxonomy":"plugin_tags","embeddable":true,"href":"https:\/\/nl.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_tags?post=301924"},{"taxonomy":"plugin_category","embeddable":true,"href":"https:\/\/nl.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_category?post=301924"},{"taxonomy":"plugin_contributors","embeddable":true,"href":"https:\/\/nl.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_contributors?post=301924"},{"taxonomy":"plugin_business_model","embeddable":true,"href":"https:\/\/nl.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_business_model?post=301924"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}