From 42b856c95868df972bae65c7cfe9459ba4fd2b74 Mon Sep 17 00:00:00 2001 From: Leonidas Milosis Date: Thu, 3 Sep 2026 12:59:47 +0300 Subject: [PATCH 1/4] Add page about new batch of abilities --- docs/features/yoast-seo-abilities/overview.md | 16 +- .../yoast-seo-abilities/posts-seo-data.md | 396 ++++++++++++++++++ sidebars.js | 1 + 3 files changed, 407 insertions(+), 6 deletions(-) create mode 100644 docs/features/yoast-seo-abilities/posts-seo-data.md diff --git a/docs/features/yoast-seo-abilities/overview.md b/docs/features/yoast-seo-abilities/overview.md index 9089b8c7..ec87bbfb 100644 --- a/docs/features/yoast-seo-abilities/overview.md +++ b/docs/features/yoast-seo-abilities/overview.md @@ -16,13 +16,12 @@ Abilities API is a standardized way for plugins to expose what they can do, [int Once an ability is registered, it is discoverable and executable from PHP, JavaScript, and the REST API. That way, AI agents (and other third-party systems) can use that ability for their purposes. ## Yoast SEO Abilities -Yoast SEO currently registers three read-only abilities that return the scores of its content analyses for the most recently modified posts: +Yoast SEO registers the following abilities: -* The SEO analysis score -* The readability analysis score -* The inclusive language analysis score +* Three **read-only** abilities that return the scores of its content analyses for the most recently modified posts — the SEO analysis score, the readability analysis score, and the inclusive language analysis score. All three are documented on the [Analysis scores](analysis-scores.md) page. +* Two abilities for working with the SEO data of individual posts — one that **reads** a post's SEO data and one that **updates** it. Both are documented on the [Post's SEO data](posts-seo-data.md) page. -All three are documented on the [Analysis scores](analysis-scores.md) page. They can also be discovered at `/wp-json/wp-abilities/v1/abilities?category=yoast-seo` along with their most relevant information. +They can all be discovered at `/wp-json/wp-abilities/v1/abilities?category=yoast-seo` along with their most relevant information. ## Use cases for the Yoast SEO Abilities @@ -32,7 +31,12 @@ Assuming that an AI agent is connected to a WordPress-enabled MCP site (details * _"Do you see the readability of my recent content going upwards or downwards?"_ * _"I want to know if I have content on my site that uses non-inclusive language. If there's indeed not inclusive language in my content, do you see a correlation between the subjects covered?"_ -That way, Yoast SEO exposes the results of its analyses to authenticated AI agents, enabling users to use AI capabilities to easily navigate through useful SEO data of their website and create reports, map out plans and perform SEO-related actions accordingly. +Beyond reading analysis scores, the agent can also read and update the SEO data of individual posts, answering requests like: +* _"Show me the SEO settings for the post at https://example.com/homemade-sourdough-bread/ — is it set to be indexed?"_ +* _"Mark my post about sourdough bread as cornerstone content and set its canonical URL to https://example.com/sourdough/."_ +* _"Noindex the post with ID 42."_ + +That way, Yoast SEO exposes both the results of its analyses and the SEO data of individual posts to authenticated AI agents, enabling users to use AI capabilities to easily navigate through useful SEO data of their website and create reports, map out plans and perform SEO-related actions accordingly. ### Third-party code For plugins interested in building features on top of Yoast SEO Analyses, a more traditional way to consume the Yoast SEO Abilities would be to use the new WP REST API endpoints. This allows information about a website's recent posts to be reliably retrieved in a structured way. diff --git a/docs/features/yoast-seo-abilities/posts-seo-data.md b/docs/features/yoast-seo-abilities/posts-seo-data.md new file mode 100644 index 00000000..ee6cb5ee --- /dev/null +++ b/docs/features/yoast-seo-abilities/posts-seo-data.md @@ -0,0 +1,396 @@ +--- +id: posts-seo-data +title: "Yoast SEO Abilities - Post's SEO data" +sidebar_label: Post's SEO data +description: This documentation explains the abilities that read and update the SEO data of individual posts. +--- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Yoast SEO registers two abilities for working with the SEO data of individual posts: one to read it and one to update it. + +| Ability | Type | Returns | +|---|---|---| +| `yoast-seo/get-post-seo-data` | Read-only | An array of SEO data objects, one per matched post | +| `yoast-seo/update-post-seo-data` | Write | The updated SEO data object for the post | + +Both are listed at `/wp-json/wp-abilities/v1/abilities?category=yoast-seo`. + +## Permissions +Unlike the [Analysis scores](analysis-scores.md) abilities, which are gated behind the Yoast SEO management capability (`wpseo_manage_options`), these two abilities are gated behind the advanced metadata capability (`wpseo_edit_advanced_metadata`) — the same capability that gates the advanced and schema fields in the editor. On top of that, per-post edit access is always enforced: only posts the current user is allowed to edit are ever returned or updated. + +## Identifying the post +Both abilities accept a `post_id` (an integer of 1 or higher) or a `permalink` (the post's URL) to locate the post. At least one identifier is required. + +`get-post-seo-data` additionally accepts a `title` search, which `update-post-seo-data` deliberately does not: an update must target a single, unambiguous post. + +## `get-post-seo-data` +Reads the SEO data for one or more posts. + +### Input +Provide at least one of the following: + +* `post_id` – the ID of the post to retrieve. An integer of 1 or higher. +* `permalink` – the permalink (URL) of the post to retrieve. +* `title` – keywords to search for in post titles. Provide a comma-separated list to search for several titles at once; each value is matched as a whole phrase against the post title, and a post matching any value is returned. At most 10 phrases are used per request; any beyond the first 10 are ignored. Results are paginated to 10 entities per page. +* `page` – the page of title-search results to return, 1-based and defaulting to 1. Matches are ordered most recently modified first, so request a later page to reach older matches. An empty result means there are no further pages. Only applies to a `title` search. + +### Output +Returns an array of [post SEO data objects](#the-post-seo-data-object), one per matched post. A lookup by `post_id` or `permalink` returns at most one object. + +### Usage +Send a _GET_ request to the ability's `/run` endpoint. To retrieve the SEO data of the post with ID 5: + +``` +/wp-json/wp-abilities/v1/abilities/yoast-seo/get-post-seo-data/run?input[post_id]=5 +``` + +To retrieve the same post by its permalink instead, pass the post's full URL (URL-encoded) as `permalink`: + +``` +/wp-json/wp-abilities/v1/abilities/yoast-seo/get-post-seo-data/run?input[permalink]=https%3A%2F%2Fexample.com%2Fhomemade-sourdough-bread%2F +``` + +To search titles for either "homemade sourdough" or "breakfast", reaching the second page of matches: + +``` +/wp-json/wp-abilities/v1/abilities/yoast-seo/get-post-seo-data/run?input[title]=homemade%20sourdough,breakfast&input[page]=2 +``` + +Doing so might yield the following result: + +```json +[ + { + "post_id": 5, + "post_title": "How to Make Homemade Sourdough Bread from Scratch", + "permalink": "https://example.com/homemade-sourdough-bread/", + "post_type": "post", + "post_status": "publish", + "seo_title": null, + "seo_title_rendered": "How to Make Homemade Sourdough Bread from Scratch - My Site", + "meta_description": "A step-by-step guide to baking sourdough at home.", + "meta_description_rendered": "A step-by-step guide to baking sourdough at home.", + "focus_keyphrase": "Homemade sourdough bread", + "canonical": null, + "canonical_rendered": "https://example.com/homemade-sourdough-bread/", + "is_cornerstone": true, + "noindex": null, + "nofollow": false, + "noimageindex": false, + "noarchive": false, + "nosnippet": false, + "open_graph_title": null, + "open_graph_title_rendered": "How to Make Homemade Sourdough Bread from Scratch", + "open_graph_description": null, + "open_graph_description_rendered": "A step-by-step guide to baking sourdough at home.", + "twitter_title": null, + "twitter_title_rendered": "How to Make Homemade Sourdough Bread from Scratch", + "twitter_description": null, + "twitter_description_rendered": "A step-by-step guide to baking sourdough at home.", + "schema_page_type": null, + "schema_article_type": null, + "seo_score": "good", + "readability_score": "ok", + "inclusive_language_score": "good" + } +] +``` + +## `update-post-seo-data` +Updates the SEO data for a single post. + +Only the fields you provide are changed; every other field is left untouched. A provided empty value (`""` or `null`, depending on the field) clears that field and lets Yoast SEO fall back to its default. Because a repeated call with the same input produces the same result, the ability is idempotent. + +### Input +Identify the post with `post_id` **or** `permalink` (a `title` search is not accepted here), then provide any of the writable fields you want to change: + +* `canonical` – the canonical URL. A string, or `null` to clear it. +* `is_cornerstone` – whether the post is marked as cornerstone content. A boolean. +* `noindex` – whether search engines should be told not to index this post. `true` sets noindex (the post is excluded from search results); `false` forces the post to be indexed; `null` clears the setting and falls back to the post-type default. +* `nofollow` – whether links on the post should not be followed. A boolean. +* `noimageindex` – whether images on the post should not be indexed. A boolean. +* `noarchive` – whether search engines should not show a cached copy of the post. A boolean. +* `nosnippet` – whether search engines should not show a snippet of the post in search results. A boolean. +* `schema_page_type` – the Schema.org page type for the post. Must be one of the supported page types, or an empty string / `null` to clear it and fall back to the default. +* `schema_article_type` – the Schema.org article type for the post. Must be one of the supported article types, or an empty string / `null` to clear it and fall back to the default. + +The Schema page and article types accepted are exactly the ones the editor accepts, including any registered through the `wpseo_schema_article_types` filter. Supplying a value outside that set returns an error and changes nothing. + +### Output +Returns the single, updated [post SEO data object](#the-post-seo-data-object), reflecting the state of the post after the update. This is the same shape as one entry of the `get-post-seo-data` array, so you can immediately confirm what was written. + +### Usage +Send a _POST_ request to the ability's `/run` endpoint with an `input` object in the body. To mark the post with ID 5 as cornerstone content: + +``` +/wp-json/wp-abilities/v1/abilities/yoast-seo/update-post-seo-data/run +``` + +```json +{ + "input": { + "post_id": 5, + "is_cornerstone": true + } +} +``` + +You can identify the post by its permalink instead, and change several fields at once: + +```json +{ + "input": { + "permalink": "https://example.com/homemade-sourdough-bread/", + "noindex": true, + "canonical": "https://example.com/sourdough/", + "schema_page_type": null + } +} +``` + +## The post SEO data object +Both abilities return the same object (`get-post-seo-data` inside an array, `update-post-seo-data` on its own). Its fields are: + +| Field | Type | Description | +|---|---|---| +| `post_id` | integer | The post ID. | +| `post_title` | string \| null | The post title. | +| `permalink` | string \| null | The post's permalink (URL). | +| `post_type` | string | The post type slug. | +| `post_status` | string \| null | The post status, such as `publish` or `draft`. | +| `seo_title` | string \| null | The custom SEO title, or `null` when none is set. | +| `seo_title_rendered` | string \| null | The SEO title as output on the front end, with the default template applied and replacement variables expanded. | +| `meta_description` | string \| null | The custom meta description, or `null` when none is set. | +| `meta_description_rendered` | string \| null | The meta description as output on the front end. | +| `focus_keyphrase` | string \| null | The focus keyphrase, or `null` when none is set. | +| `canonical` | string \| null | The custom canonical URL, or `null` when none is set. | +| `canonical_rendered` | string \| null | The canonical URL as output on the front end. | +| `is_cornerstone` | boolean | Whether the post is marked as cornerstone content. | +| `noindex` | boolean \| null | Whether search engines are told not to index this post. `true` means noindex, `false` forces indexing, `null` means the post-type default applies. | +| `nofollow` | boolean | Whether links on the post are not to be followed. | +| `noimageindex` | boolean | Whether images on the post are not to be indexed. | +| `noarchive` | boolean | Whether search engines are told not to show a cached copy. | +| `nosnippet` | boolean | Whether search engines are told not to show a snippet. | +| `open_graph_title` | string \| null | The custom Open Graph title. | +| `open_graph_title_rendered` | string \| null | The Open Graph title as output on the front end. | +| `open_graph_description` | string \| null | The custom Open Graph description. | +| `open_graph_description_rendered` | string \| null | The Open Graph description as output on the front end. | +| `twitter_title` | string \| null | The custom Twitter title. | +| `twitter_title_rendered` | string \| null | The Twitter title as output on the front end. | +| `twitter_description` | string \| null | The custom Twitter description. | +| `twitter_description_rendered` | string \| null | The Twitter description as output on the front end. | +| `schema_page_type` | string \| null | The Schema.org page type for the post. | +| `schema_article_type` | string \| null | The Schema.org article type for the post. | +| `seo_score` | string | The result of the SEO analysis when the post was last saved: one of `na`, `bad`, `ok`, or `good`. | +| `readability_score` | string | The result of the readability analysis when the post was last saved. | +| `inclusive_language_score` | string | The result of the inclusive language analysis when the post was last saved. | + +The `*_rendered` companion of a field carries the value as actually output on the front end: the global default template applied where no custom value is set, with replacement variables expanded. It is `null` when nothing is output. This lets you distinguish a field the user left empty (a `null` stored value with a non-`null` rendered value) from what a visitor and search engine actually see. + +## Full definition +A _GET_ request to `/wp-json/wp-abilities/v1/abilities` returns the complete definition of each ability: + + + + +```json +{ + "name": "yoast-seo/get-post-seo-data", + "label": "Get Post SEO Data", + "description": "Get the SEO data for a post. Identify the post by post_id, by permalink (URL), or by title keywords; the title may be a comma-separated list and returns the SEO data for every post matching any of the values, paginated most recently modified first (use the page parameter to reach older matches). At least one identifier is required. Only posts the current user is allowed to edit are returned.", + "category": "yoast-seo", + "input_schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "post_id": { + "type": "integer", + "description": "The ID of the post to retrieve.", + "minimum": 1 + }, + "permalink": { + "type": "string", + "description": "The permalink (URL) of the post to retrieve." + }, + "title": { + "type": "string", + "description": "Keywords to search for in post titles. Provide a comma-separated list to search for several titles at once; each value is matched as a whole phrase against the post title, and a post matching any value is returned. At most 10 phrases are used per request; any beyond the first 10 are ignored. Results are paginated to 10 entities per page; see the page parameter." + }, + "page": { + "type": "integer", + "description": "The page of title-search results to return, 1-based and defaulting to 1. Matches are ordered most recently modified first, so request a later page to reach older matches. An empty result means there are no further pages. Only applies to a title search.", + "minimum": 1, + "default": 1 + } + } + }, + "output_schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "post_id": { "type": "integer" }, + "post_title": { "type": ["string", "null"] }, + "permalink": { "type": ["string", "null"] }, + "post_type": { "type": "string" }, + "post_status": { "type": ["string", "null"] }, + "seo_title": { "type": ["string", "null"] }, + "seo_title_rendered": { "type": ["string", "null"] }, + "meta_description": { "type": ["string", "null"] }, + "meta_description_rendered": { "type": ["string", "null"] }, + "focus_keyphrase": { "type": ["string", "null"] }, + "canonical": { "type": ["string", "null"] }, + "canonical_rendered": { "type": ["string", "null"] }, + "is_cornerstone": { "type": "boolean" }, + "noindex": { "type": ["boolean", "null"] }, + "nofollow": { "type": "boolean" }, + "noimageindex": { "type": "boolean" }, + "noarchive": { "type": "boolean" }, + "nosnippet": { "type": "boolean" }, + "open_graph_title": { "type": ["string", "null"] }, + "open_graph_title_rendered": { "type": ["string", "null"] }, + "open_graph_description": { "type": ["string", "null"] }, + "open_graph_description_rendered": { "type": ["string", "null"] }, + "twitter_title": { "type": ["string", "null"] }, + "twitter_title_rendered": { "type": ["string", "null"] }, + "twitter_description": { "type": ["string", "null"] }, + "twitter_description_rendered": { "type": ["string", "null"] }, + "schema_page_type": { "type": ["string", "null"] }, + "schema_article_type": { "type": ["string", "null"] }, + "seo_score": { "type": "string", "enum": ["na", "bad", "ok", "good"] }, + "readability_score": { "type": "string", "enum": ["na", "bad", "ok", "good"] }, + "inclusive_language_score": { "type": "string", "enum": ["na", "bad", "ok", "good"] } + } + } + }, + "meta": { + "annotations": { + "readonly": true, + "destructive": false, + "idempotent": true + }, + "show_in_rest": true, + "mcp": { + "public": true + } + } +} +``` + + + + +```json +{ + "name": "yoast-seo/update-post-seo-data", + "label": "Update Post SEO Data", + "description": "Update the SEO data for a single post. Identify the post by post_id or by permalink (URL). Only the fields you provide are changed; a provided empty value clears that field. Only posts the current user is allowed to edit can be updated.", + "category": "yoast-seo", + "input_schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "post_id": { + "type": "integer", + "description": "The ID of the post to update.", + "minimum": 1 + }, + "permalink": { + "type": "string", + "description": "The permalink (URL) of the post to update." + }, + "canonical": { "type": ["string", "null"] }, + "is_cornerstone": { "type": "boolean" }, + "noindex": { + "type": ["boolean", "null"], + "description": "Whether search engines should be told not to index this post. true sets noindex (the post is excluded from search results); false forces the post to be indexed; null clears the setting and falls back to the post-type default." + }, + "nofollow": { "type": "boolean" }, + "noimageindex": { "type": "boolean" }, + "noarchive": { "type": "boolean" }, + "nosnippet": { "type": "boolean" }, + "schema_page_type": { + "type": ["string", "null"], + "description": "The Schema.org page type for the post. Must be one of the supported page types. Use null to clear it and fall back to the default.", + "enum": ["...supported page types...", "", null] + }, + "schema_article_type": { + "type": ["string", "null"], + "description": "The Schema.org article type for the post. Must be one of the supported article types. Use null to clear it and fall back to the default.", + "enum": ["...supported article types...", "", null] + } + } + }, + "output_schema": { + "type": "object", + "properties": { + "post_id": { "type": "integer" }, + "post_title": { "type": ["string", "null"] }, + "permalink": { "type": ["string", "null"] }, + "post_type": { "type": "string" }, + "post_status": { "type": ["string", "null"] }, + "seo_title": { "type": ["string", "null"] }, + "seo_title_rendered": { "type": ["string", "null"] }, + "meta_description": { "type": ["string", "null"] }, + "meta_description_rendered": { "type": ["string", "null"] }, + "focus_keyphrase": { "type": ["string", "null"] }, + "canonical": { "type": ["string", "null"] }, + "canonical_rendered": { "type": ["string", "null"] }, + "is_cornerstone": { "type": "boolean" }, + "noindex": { "type": ["boolean", "null"] }, + "nofollow": { "type": "boolean" }, + "noimageindex": { "type": "boolean" }, + "noarchive": { "type": "boolean" }, + "nosnippet": { "type": "boolean" }, + "open_graph_title": { "type": ["string", "null"] }, + "open_graph_title_rendered": { "type": ["string", "null"] }, + "open_graph_description": { "type": ["string", "null"] }, + "open_graph_description_rendered": { "type": ["string", "null"] }, + "twitter_title": { "type": ["string", "null"] }, + "twitter_title_rendered": { "type": ["string", "null"] }, + "twitter_description": { "type": ["string", "null"] }, + "twitter_description_rendered": { "type": ["string", "null"] }, + "schema_page_type": { "type": ["string", "null"] }, + "schema_article_type": { "type": ["string", "null"] }, + "seo_score": { "type": "string", "enum": ["na", "bad", "ok", "good"] }, + "readability_score": { "type": "string", "enum": ["na", "bad", "ok", "good"] }, + "inclusive_language_score": { "type": "string", "enum": ["na", "bad", "ok", "good"] } + } + }, + "meta": { + "annotations": { + "readonly": false, + "destructive": false, + "idempotent": true + }, + "show_in_rest": true, + "mcp": { + "public": true + } + } +} +``` + + + + +The `schema_page_type` and `schema_article_type` enums above are abbreviated: at runtime they list every supported Schema.org page and article type, plus an empty string and `null` so the field can be cleared. + +## How to disable them programmatically +You can unregister either of these abilities through the WordPress-native `wp_abilities_api_init` action. Remove a name from the array to keep that ability enabled: + +```php +add_action( 'wp_abilities_api_init', function() { + $abilities = [ + 'yoast-seo/get-post-seo-data', + 'yoast-seo/update-post-seo-data', + ]; + foreach ( $abilities as $ability ) { + if ( wp_has_ability( $ability ) ) { + wp_unregister_ability( $ability ); + } + } + }, 20 +); // Run after Yoast SEO registers at default priority 10 +``` diff --git a/sidebars.js b/sidebars.js index e023937c..0791a777 100644 --- a/sidebars.js +++ b/sidebars.js @@ -306,6 +306,7 @@ module.exports = { items: [ "features/yoast-seo-abilities/overview", "features/yoast-seo-abilities/analysis-scores", + "features/yoast-seo-abilities/posts-seo-data", ], }, ], From 1cb4d0774b8b971a1698a779cee4bf9c52cfd6f2 Mon Sep 17 00:00:00 2001 From: Leonidas Milosis Date: Thu, 3 Sep 2026 13:15:53 +0300 Subject: [PATCH 2/4] Add annotation info on all abilities --- docs/features/yoast-seo-abilities/analysis-scores.md | 11 +++++++++++ docs/features/yoast-seo-abilities/overview.md | 9 +++++++++ docs/features/yoast-seo-abilities/posts-seo-data.md | 12 +++++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/features/yoast-seo-abilities/analysis-scores.md b/docs/features/yoast-seo-abilities/analysis-scores.md index 46547bf8..f03b952b 100644 --- a/docs/features/yoast-seo-abilities/analysis-scores.md +++ b/docs/features/yoast-seo-abilities/analysis-scores.md @@ -19,6 +19,17 @@ All three take the same input and return the same shape, with one exception: the All three are listed at `/wp-json/wp-abilities/v1/abilities?category=yoast-seo`. +## Annotations +Each ability declares a set of [behavior annotations](overview.md#annotations) — read-only, destructive, and idempotent — as hints for AI agents and other MCP clients. All three score abilities share the same values: + +| Ability | Read-only | Destructive | Idempotent | +|---|---|---|---| +| `yoast-seo/get-seo-scores` | Yes | No | Yes | +| `yoast-seo/get-readability-scores` | Yes | No | Yes | +| `yoast-seo/get-inclusive-language-scores` | Yes | No | Yes | + +Being read-only, they never modify your content; the destructive and idempotent hints are included for completeness but carry no practical weight for a read-only ability. + ## Input Each ability takes a single optional argument: diff --git a/docs/features/yoast-seo-abilities/overview.md b/docs/features/yoast-seo-abilities/overview.md index ec87bbfb..4889705b 100644 --- a/docs/features/yoast-seo-abilities/overview.md +++ b/docs/features/yoast-seo-abilities/overview.md @@ -23,6 +23,15 @@ Yoast SEO registers the following abilities: They can all be discovered at `/wp-json/wp-abilities/v1/abilities?category=yoast-seo` along with their most relevant information. +## Annotations +Every ability declares a set of behavior annotations in its `meta.annotations`. They are hints — primarily for AI agents and other MCP clients — describing how the ability behaves, so a client can reason about how risky it is to call: + +* **Read-only** – when `true`, the ability only reads data and never changes anything on your site. +* **Destructive** – only meaningful when the ability is not read-only. When `true`, the ability may overwrite or remove existing data in a way that is not easily reversible; when `false`, any changes it makes are additive or reversible. +* **Idempotent** – only meaningful when the ability is not read-only. When `true`, calling the ability again with the same input has no further effect beyond the first call. + +The annotations for each ability are listed on its documentation page: [Analysis scores](analysis-scores.md#annotations) and [Post's SEO data](posts-seo-data.md#annotations). + ## Use cases for the Yoast SEO Abilities ### AI agents diff --git a/docs/features/yoast-seo-abilities/posts-seo-data.md b/docs/features/yoast-seo-abilities/posts-seo-data.md index ee6cb5ee..d3772250 100644 --- a/docs/features/yoast-seo-abilities/posts-seo-data.md +++ b/docs/features/yoast-seo-abilities/posts-seo-data.md @@ -19,6 +19,16 @@ Both are listed at `/wp-json/wp-abilities/v1/abilities?category=yoast-seo`. ## Permissions Unlike the [Analysis scores](analysis-scores.md) abilities, which are gated behind the Yoast SEO management capability (`wpseo_manage_options`), these two abilities are gated behind the advanced metadata capability (`wpseo_edit_advanced_metadata`) — the same capability that gates the advanced and schema fields in the editor. On top of that, per-post edit access is always enforced: only posts the current user is allowed to edit are ever returned or updated. +## Annotations +Each ability declares a set of [behavior annotations](overview.md#annotations) — read-only, destructive, and idempotent — as hints for AI agents and other MCP clients: + +| Ability | Read-only | Destructive | Idempotent | +|---|---|---|---| +| `yoast-seo/get-post-seo-data` | Yes | No | Yes | +| `yoast-seo/update-post-seo-data` | No | No | Yes | + +`update-post-seo-data` is not read-only, since it writes post data. It is non-destructive because it only edits the post's SEO metadata fields — it never deletes the post or its content — so a change is always bounded to those fields rather than a wholesale, irreversible operation. It is idempotent because it only sets the specific fields you provide and sending the same request again leaves the post in the same state, rather than compounding the change. + ## Identifying the post Both abilities accept a `post_id` (an integer of 1 or higher) or a `permalink` (the post's URL) to locate the post. At least one identifier is required. @@ -100,7 +110,7 @@ Doing so might yield the following result: ## `update-post-seo-data` Updates the SEO data for a single post. -Only the fields you provide are changed; every other field is left untouched. A provided empty value (`""` or `null`, depending on the field) clears that field and lets Yoast SEO fall back to its default. Because a repeated call with the same input produces the same result, the ability is idempotent. +Only the fields you provide are changed; every other field is left untouched. A provided empty value (`""` or `null`, depending on the field) clears that field and lets Yoast SEO fall back to its default. ### Input Identify the post with `post_id` **or** `permalink` (a `title` search is not accepted here), then provide any of the writable fields you want to change: From 29656153ad45c9f45b811be7a106eb059bdc9bf1 Mon Sep 17 00:00:00 2001 From: Leonidas Milosis Date: Thu, 3 Sep 2026 15:44:09 +0300 Subject: [PATCH 3/4] Improve readability of ability docs --- .../yoast-seo-abilities/analysis-scores.md | 2 +- docs/features/yoast-seo-abilities/overview.md | 18 +++++++++++++----- .../yoast-seo-abilities/posts-seo-data.md | 18 ++++++++++++------ 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/docs/features/yoast-seo-abilities/analysis-scores.md b/docs/features/yoast-seo-abilities/analysis-scores.md index f03b952b..c1cccdac 100644 --- a/docs/features/yoast-seo-abilities/analysis-scores.md +++ b/docs/features/yoast-seo-abilities/analysis-scores.md @@ -20,7 +20,7 @@ All three take the same input and return the same shape, with one exception: the All three are listed at `/wp-json/wp-abilities/v1/abilities?category=yoast-seo`. ## Annotations -Each ability declares a set of [behavior annotations](overview.md#annotations) — read-only, destructive, and idempotent — as hints for AI agents and other MCP clients. All three score abilities share the same values: +Each ability declares a set of [behavior annotations](overview.md#annotations) as hints for AI agents and other MCP clients. All three score abilities share the same values: | Ability | Read-only | Destructive | Idempotent | |---|---|---|---| diff --git a/docs/features/yoast-seo-abilities/overview.md b/docs/features/yoast-seo-abilities/overview.md index 4889705b..a0f5278d 100644 --- a/docs/features/yoast-seo-abilities/overview.md +++ b/docs/features/yoast-seo-abilities/overview.md @@ -18,13 +18,18 @@ Once an ability is registered, it is discoverable and executable from PHP, JavaS ## Yoast SEO Abilities Yoast SEO registers the following abilities: -* Three **read-only** abilities that return the scores of its content analyses for the most recently modified posts — the SEO analysis score, the readability analysis score, and the inclusive language analysis score. All three are documented on the [Analysis scores](analysis-scores.md) page. -* Two abilities for working with the SEO data of individual posts — one that **reads** a post's SEO data and one that **updates** it. Both are documented on the [Post's SEO data](posts-seo-data.md) page. +* Three **read-only** [Analysis scores](analysis-scores.md) abilities that return the scores of its content analyses for the most recently modified posts: + * the SEO analysis score + * the readability analysis score + * the inclusive language analysis score. +* Two abilities [Post's SEO data](posts-seo-data.md) abilities for working with the SEO data of individual posts + * one that **reads** a post's SEO data + * one that **updates** it. They can all be discovered at `/wp-json/wp-abilities/v1/abilities?category=yoast-seo` along with their most relevant information. ## Annotations -Every ability declares a set of behavior annotations in its `meta.annotations`. They are hints — primarily for AI agents and other MCP clients — describing how the ability behaves, so a client can reason about how risky it is to call: +Every ability declares a set of behavior annotations in its `meta.annotations`. They are hints (primarily for AI agents and other MCP clients) describing how the ability behaves, so a client can reason about how risky it is to call: * **Read-only** – when `true`, the ability only reads data and never changes anything on your site. * **Destructive** – only meaningful when the ability is not read-only. When `true`, the ability may overwrite or remove existing data in a way that is not easily reversible; when `false`, any changes it makes are additive or reversible. @@ -41,14 +46,17 @@ Assuming that an AI agent is connected to a WordPress-enabled MCP site (details * _"I want to know if I have content on my site that uses non-inclusive language. If there's indeed not inclusive language in my content, do you see a correlation between the subjects covered?"_ Beyond reading analysis scores, the agent can also read and update the SEO data of individual posts, answering requests like: -* _"Show me the SEO settings for the post at https://example.com/homemade-sourdough-bread/ — is it set to be indexed?"_ +* _"Show me the SEO settings for the post at https://example.com/homemade-sourdough-bread/. Is it set to be indexed?"_ * _"Mark my post about sourdough bread as cornerstone content and set its canonical URL to https://example.com/sourdough/."_ * _"Noindex the post with ID 42."_ +* Or even a bulk fetch and update of posts: _"Get me the SEO titles of all posts about hiking boots and noindex the ones without a custom SEO title"_ That way, Yoast SEO exposes both the results of its analyses and the SEO data of individual posts to authenticated AI agents, enabling users to use AI capabilities to easily navigate through useful SEO data of their website and create reports, map out plans and perform SEO-related actions accordingly. ### Third-party code -For plugins interested in building features on top of Yoast SEO Analyses, a more traditional way to consume the Yoast SEO Abilities would be to use the new WP REST API endpoints. This allows information about a website's recent posts to be reliably retrieved in a structured way. +AI agents are not the only consumers. Plugins and other integrations can call the same abilities directly through the WP REST API, without any AI in the loop, by sending requests to each ability's `/run` endpoint. + +Because every ability declares structured input and output schemas, this gives integrators a reliable, versioned way to read a site's analysis scores and post SEO data, and to update that SEO data, in a predictable shape, rather than depending on Yoast SEO's internal storage. ## Prerequisites * WordPress 6.9 or higher. diff --git a/docs/features/yoast-seo-abilities/posts-seo-data.md b/docs/features/yoast-seo-abilities/posts-seo-data.md index d3772250..8b9b846c 100644 --- a/docs/features/yoast-seo-abilities/posts-seo-data.md +++ b/docs/features/yoast-seo-abilities/posts-seo-data.md @@ -17,22 +17,22 @@ Yoast SEO registers two abilities for working with the SEO data of individual po Both are listed at `/wp-json/wp-abilities/v1/abilities?category=yoast-seo`. ## Permissions -Unlike the [Analysis scores](analysis-scores.md) abilities, which are gated behind the Yoast SEO management capability (`wpseo_manage_options`), these two abilities are gated behind the advanced metadata capability (`wpseo_edit_advanced_metadata`) — the same capability that gates the advanced and schema fields in the editor. On top of that, per-post edit access is always enforced: only posts the current user is allowed to edit are ever returned or updated. +Unlike the [Analysis scores](analysis-scores.md) abilities, which are gated behind the Yoast SEO management capability (`wpseo_manage_options`), these two abilities are gated behind the advanced metadata capability (`wpseo_edit_advanced_metadata`), the same capability that gates the advanced and schema fields in the editor. On top of that, per-post edit access is always enforced: only posts the current user is allowed to edit are ever returned or updated. ## Annotations -Each ability declares a set of [behavior annotations](overview.md#annotations) — read-only, destructive, and idempotent — as hints for AI agents and other MCP clients: +Each ability declares a set of [behavior annotations](overview.md#annotations) as hints for AI agents and other MCP clients: | Ability | Read-only | Destructive | Idempotent | |---|---|---|---| | `yoast-seo/get-post-seo-data` | Yes | No | Yes | | `yoast-seo/update-post-seo-data` | No | No | Yes | -`update-post-seo-data` is not read-only, since it writes post data. It is non-destructive because it only edits the post's SEO metadata fields — it never deletes the post or its content — so a change is always bounded to those fields rather than a wholesale, irreversible operation. It is idempotent because it only sets the specific fields you provide and sending the same request again leaves the post in the same state, rather than compounding the change. +`update-post-seo-data` is not read-only, since it writes post data. It is non-destructive because it only edits the post's SEO metadata fields (it never deletes the post or its content) so a change is always bounded to those fields rather than a wholesale, irreversible operation. It is idempotent because it only sets the specific fields you provide and sending the same request again leaves the post in the same state, rather than compounding the change. ## Identifying the post Both abilities accept a `post_id` (an integer of 1 or higher) or a `permalink` (the post's URL) to locate the post. At least one identifier is required. -`get-post-seo-data` additionally accepts a `title` search, which `update-post-seo-data` deliberately does not: an update must target a single, unambiguous post. +`get-post-seo-data` additionally accepts a `title` search, which `update-post-seo-data` deliberately does not, since an update must target a single, unambiguous post. That way, users can describe in human language what post they want data for and the ability will can find it quickly and efficiently. ## `get-post-seo-data` Reads the SEO data for one or more posts. @@ -42,8 +42,14 @@ Provide at least one of the following: * `post_id` – the ID of the post to retrieve. An integer of 1 or higher. * `permalink` – the permalink (URL) of the post to retrieve. -* `title` – keywords to search for in post titles. Provide a comma-separated list to search for several titles at once; each value is matched as a whole phrase against the post title, and a post matching any value is returned. At most 10 phrases are used per request; any beyond the first 10 are ignored. Results are paginated to 10 entities per page. -* `page` – the page of title-search results to return, 1-based and defaulting to 1. Matches are ordered most recently modified first, so request a later page to reach older matches. An empty result means there are no further pages. Only applies to a `title` search. +* `title` – keywords to search for in post titles. + * A comma-separated list to search for several titles at once is expected + * each value is matched as a whole phrase against the post title, and a post matching any value is returned. + * At most 10 phrases are used per request; any beyond the first 10 are ignored. + * Results are paginated to 10 entities per page. +* `page` – the page of title-search results to return, 1-based and defaulting to 1. + * Matches are ordered most recently modified first, so request a later page to reach older matches. + * An empty result means there are no further pages. Only applies to a `title` search. ### Output Returns an array of [post SEO data objects](#the-post-seo-data-object), one per matched post. A lookup by `post_id` or `permalink` returns at most one object. From cd6b0d790f824fac3a9969200129f4da99280138 Mon Sep 17 00:00:00 2001 From: Leonidas Milosis Date: Thu, 3 Sep 2026 16:18:49 +0300 Subject: [PATCH 4/4] Fix destructive claims --- docs/features/yoast-seo-abilities/overview.md | 2 +- docs/features/yoast-seo-abilities/posts-seo-data.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/features/yoast-seo-abilities/overview.md b/docs/features/yoast-seo-abilities/overview.md index a0f5278d..03a20f6c 100644 --- a/docs/features/yoast-seo-abilities/overview.md +++ b/docs/features/yoast-seo-abilities/overview.md @@ -32,7 +32,7 @@ They can all be discovered at `/wp-json/wp-abilities/v1/abilities?category=yoast Every ability declares a set of behavior annotations in its `meta.annotations`. They are hints (primarily for AI agents and other MCP clients) describing how the ability behaves, so a client can reason about how risky it is to call: * **Read-only** – when `true`, the ability only reads data and never changes anything on your site. -* **Destructive** – only meaningful when the ability is not read-only. When `true`, the ability may overwrite or remove existing data in a way that is not easily reversible; when `false`, any changes it makes are additive or reversible. +* **Destructive** – only meaningful when the ability is not read-only. When `true`, the ability may overwrite or remove existing data in a way that is not easily reversible; when `false`, any changes it makes are additive or reversible. When `null`, the ability makes no claim either way, so a client should treat it as potentially destructive. * **Idempotent** – only meaningful when the ability is not read-only. When `true`, calling the ability again with the same input has no further effect beyond the first call. The annotations for each ability are listed on its documentation page: [Analysis scores](analysis-scores.md#annotations) and [Post's SEO data](posts-seo-data.md#annotations). diff --git a/docs/features/yoast-seo-abilities/posts-seo-data.md b/docs/features/yoast-seo-abilities/posts-seo-data.md index 8b9b846c..d50d6f6e 100644 --- a/docs/features/yoast-seo-abilities/posts-seo-data.md +++ b/docs/features/yoast-seo-abilities/posts-seo-data.md @@ -25,9 +25,9 @@ Each ability declares a set of [behavior annotations](overview.md#annotations) a | Ability | Read-only | Destructive | Idempotent | |---|---|---|---| | `yoast-seo/get-post-seo-data` | Yes | No | Yes | -| `yoast-seo/update-post-seo-data` | No | No | Yes | +| `yoast-seo/update-post-seo-data` | No | Not declared (`null`) | Yes | -`update-post-seo-data` is not read-only, since it writes post data. It is non-destructive because it only edits the post's SEO metadata fields (it never deletes the post or its content) so a change is always bounded to those fields rather than a wholesale, irreversible operation. It is idempotent because it only sets the specific fields you provide and sending the same request again leaves the post in the same state, rather than compounding the change. +`update-post-seo-data` is not read-only, since it writes post data. Its destructive hint is left undeclared (`null`) rather than `false`: although the ability only edits Yoast's SEO metadata fields and never touches the post's content, writing a field overwrites its previous value and a provided empty value clears it, so some data loss is possible and the ability makes no non-destructive guarantee. It is idempotent because it only sets the specific fields you provide and sending the same request again leaves the post in the same state, rather than compounding the change. ## Identifying the post Both abilities accept a `post_id` (an integer of 1 or higher) or a `permalink` (the post's URL) to locate the post. At least one identifier is required. @@ -377,7 +377,7 @@ A _GET_ request to `/wp-json/wp-abilities/v1/abilities` returns the complete def "meta": { "annotations": { "readonly": false, - "destructive": false, + "destructive": null, "idempotent": true }, "show_in_rest": true,