WordPress 7.0 ships three AI building blocks in core that, for the first time, fit together: the PHP AI Client (wp_ai_client_prompt()), the JavaScript client for the Abilities API (@wordpress/abilities), and a stable contract for the MCP Adapter to opt abilities into. The PHP side of the Abilities API has been around since 6.9; 7.0 is when the rest of the surface area lands and the three pieces start to make sense as a single story.
This post walks through the smallest end-to-end thing you can build with all of it: a Content Summarization plugin. A button in the post sidebar, an AI-generated summary, inserted as a quote block at the top of the post. It’s a simplified version of the same feature that ships in the official WordPress/ai reference plugin — stripped down so every line is yours to read.
By the end you’ll have a plugin that:
- Calls an AI provider from PHP without caring which provider
- Registers an Ability that auto-exposes itself as a REST endpoint
- Hooks into the block editor with a button that calls that endpoint and inserts the result
Prerequisites
You need WordPress 7.0 and the ai plugin installed and activated. Once both are running, head to Settings → Connectors and configure an API key for one of:
- Anthropic — text with Claude
- OpenAI — text and images with GPT and DALL·E
- Google — text and images with Gemini and Imagen
That’s it for setup. The PHP AI Client lives in core; the Connectors UI lives in the AI plugin.
The Plugin Header
Create a folder in wp-content/plugins/ (call it content-summarizer or whatever you like). Inside, add plugin.php:
<?php
/**
* Plugin Name: Content Summarizer
* Description: AI-powered post summarization for the block editor.
* Version: 0.1.0
* Requires at least: 7.0
* Requires PHP: 8.1
* Text Domain: summarizer
*/
if ( ! function_exists( 'wp_ai_client_prompt' ) ) {
return;
}
require_once __DIR__ . '/includes/summarizer.php';PHPThe function_exists() guard matters. wp_ai_client_prompt() ships in 7.0 core, and if the plugin is somehow active on an older WordPress (mid-upgrade, mid-rollback) you want it to exit silently rather than fatal.
Create includes/summarizer.php next to plugin.php. This is where the rest of the code lives.
First AI Request
Before wiring anything to the editor, let’s confirm the AI client is alive. Add this to includes/summarizer.php:
<?php
function summarizer_test_ai_connection() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$response = wp_ai_client_prompt(
'Say hello in exactly one sentence.'
)->generate_text();
if ( is_wp_error( $response ) ) {
printf(
'<div class="notice notice-error"><p>AI Error: %s</p></div>',
esc_html( $response->get_error_message() )
);
return;
}
printf(
'<div class="notice notice-success"><p>%s</p></div>',
esc_html( $response )
);
}
add_action( 'admin_notices', 'summarizer_test_ai_connection' );PHPLoad any admin page. You should get a green notice with a one-sentence greeting from whichever provider you configured.
wp_ai_client_prompt() returns a builder object, not a result. Every fluent method (using_*(), with_*(), as_*()) returns $this. The HTTP request only fires when you call a terminal generate_*() method. For a one-shot text completion, generate_text() is the whole API.
Provider switching is invisible from PHP. Swap your API key from Anthropic to OpenAI and the same wp_ai_client_prompt() call now goes to GPT. That’s the point.
Once you’ve seen the greeting, delete summarizer_test_ai_connection and its add_action line. We’re done with it.
Registering an Ability
The temptation at this point is to write a register_rest_route() call, accept some JSON, call wp_ai_client_prompt(), return a string. That works, but it leaves you maintaining the schema, the validation, the permission check, and the JS client all by hand. And it doesn’t expose anything to MCP-aware agents.
The Abilities API gives you all of that for free. You declare an ability — slug, input schema, output schema, permission callback, execute callback — and WordPress generates the REST endpoint, runs schema validation, and optionally exposes it to MCP.
First, register a category. Categories group abilities in the Abilities Explorer UI (under Settings → AI) and let JS clients filter:
function summarizer_register_ability_category() {
wp_register_ability_category(
'custom-abilties',
array(
'label' => __( 'Summarizer', 'summarizer' ),
'description' => __( 'Content summarization abilities.', 'summarizer' ),
)
);
}
add_action( 'wp_abilities_api_categories_init', 'summarizer_register_ability_category' );PHPCategories register on wp_abilities_api_categories_init, which fires before wp_abilities_api_init, so by the time abilities register, the category is already there.
Now the ability:
function summarizer_register_summarization_ability() {
wp_register_ability(
'ryanwelcher/summarization',
array(
'label' => __( 'Summarize Content', 'summarizer' ),
'description' => __( 'Generates a plain-text summary of the provided content.', 'summarizer' ),
'category' => 'custom-abilties',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'content' => array(
'type' => 'string',
'description' => 'The content to summarize.',
),
'length' => array(
'type' => 'string',
'enum' => array( 'short', 'medium', 'long' ),
'default' => 'medium',
'description' => 'The desired length of the summary.',
),
),
'required' => array( 'content' ),
),
'output_schema' => array(
'type' => 'string',
'description' => 'The generated summary.',
),
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'execute_callback' => 'summarizer_execute_summarization',
'meta' => array(
'show_in_rest' => true,
'mcp' => array(
'public' => true,
),
),
)
);
}
add_action( 'wp_abilities_api_init', 'summarizer_register_summarization_ability' );PHPTwo lines in meta do most of the heavy lifting:
show_in_rest => trueauto-generates the REST endpoint at/wp-json/wp-abilities/v1/abilities/ryanwelcher/summarization/run. Noregister_rest_route()call. The route pattern is/<namespace>/<ability-slug>/run.mcp.public => trueopts the ability into the MCP Adapter’s default server, so MCP-aware agents like Claude Desktop and Cursor can discover and call it.
The input_schema isn’t just documentation — WordPress validates incoming requests against it before execute_callback runs. If a caller sends length: "gigantic", the REST layer rejects it with ability_invalid_input and your callback never runs. You don’t burn an AI call on bad input.
The execute callback is where the actual work happens:
function summarizer_execute_summarization( $input ) {
$content = $input['content'];
$length = $input['length'] ?? 'medium';
$length_instruction = array(
'short' => 'Write a single sentence summary of no more than 25 words.',
'medium' => 'Write a 2-3 sentence summary of 25-80 words.',
'long' => 'Write a 4-6 sentence summary of 80-160 words.',
);
$prompt = sprintf(
"Summarize the following content. %s Use plain text only — no markdown, no bullet points. Do not introduce information not present in the source.\n\nContent:\n%s",
$length_instruction[ $length ],
$content
);
return wp_ai_client_prompt( $prompt )->generate_text();
}PHPReturning the value directly is fine: a string passes through to the caller, a WP_Error becomes a typed REST error.
Confirm It Registered
Go to Settings → AI → Abilities Explorer. You should see your Summarizer category with Summarize Content (ryanwelcher/summarization) listed underneath. If it’s there, registration worked.
You can hit the endpoint directly from your browser DevTools console — no curl, no application password. Open any non-editor admin page (Posts → All Posts is fine; avoid the editor itself because wp.apiFetch isn’t reliably on the top-level window inside the editor iframe), open DevTools, and paste:
const summary = await wp.apiFetch( {
path: '/wp-abilities/v1/abilities/ryanwelcher/summarization/run',
method: 'POST',
data: {
input: {
content: 'WordPress is open source software you can use to create a beautiful website, blog, or app.',
length: 'long',
},
},
} );
console.log( 'Summary:', summary );JavaScriptTwo contract details to notice — the JavaScript in the next section relies on both:
- The payload goes inside an
inputwrapper. That’s the REST contract the Abilities API generates from yourinput_schema. - The response is the summarized text.
Wiring It to the Block Editor
Now the editor integration. We’re going to add a button to the post sidebar that reads the current blocks, sends them to our ability, and inserts the response as a quote block at the top.
First, enqueue the script. Add this to the bottom of includes/summarizer.php:
function summarizer_enqueue_editor_assets() {
$asset_file = plugin_dir_path( __DIR__ ) . 'build/index.asset.php';
if ( ! file_exists( $asset_file ) ) {
return;
}
$assets = require $asset_file;
wp_enqueue_script(
'summarizer',
plugins_url( 'build/index.js', __DIR__ ),
$assets['dependencies'],
$assets['version'],
true
);
}
add_action( 'enqueue_block_editor_assets', 'summarizer_enqueue_editor_assets' );PHPA plain wp_enqueue_script(). No script module loader, no special handling. We’re hitting the REST endpoint with @wordpress/api-fetch, which is just another classic-script package — webpack picks it up like any other @wordpress/* import.
Set up a src/index.js and a package.json with @wordpress/scripts so you can npm start. Then drop this into src/index.js:
import { registerPlugin } from '@wordpress/plugins';
import { PluginPostStatusInfo } from '@wordpress/editor';
import { useState } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import { serialize, createBlock } from '@wordpress/blocks';
import { Button, SelectControl } from '@wordpress/components';
import apiFetch from '@wordpress/api-fetch';
const SummarizationPlugin = () => {
const [ isLoading, setIsLoading ] = useState( false );
const [ length, setLength ] = useState( 'medium' );
const blocks = useSelect(
( select ) => select( 'core/block-editor' ).getBlocks(),
[]
);
const { insertBlock } = useDispatch( 'core/block-editor' );
const handleClick = async () => {
setIsLoading( true );
const content = serialize( blocks );
try {
const summary = await apiFetch( {
path: '/wp-abilities/v1/abilities/ryanwelcher/summarization/run',
method: 'POST',
data: {
input: {
content,
length,
},
},
} );
// Build the inner paragraph first, then wrap it in a quote so the
// summary is visually distinct from the post's regular content.
const paragraphBlock = createBlock( 'core/paragraph', {
content: summary,
} );
const quoteBlock = createBlock(
'core/quote',
{ citation: 'Custom Summarizer' },
[ paragraphBlock ]
);
// Insert at index 0 — the very top of the post content.
insertBlock( quoteBlock, 0 );
} catch ( e ) {
console.error( e );
}
setIsLoading( false );
};
return (
<PluginPostStatusInfo>
<SelectControl
label="Summary length"
value={ length }
disabled={ isLoading }
options={ [
{ label: 'Short', value: 'short' },
{ label: 'Medium', value: 'medium' },
{ label: 'Long', value: 'long' },
] }
onChange={ setLength }
/>
<Button
variant="primary"
onClick={ handleClick }
isBusy={ isLoading }
>
{ isLoading ? 'Generating…' : 'Generate AI Summary' }
</Button>
</PluginPostStatusInfo>
);
};
registerPlugin( 'summarizer', { render: SummarizationPlugin } );JavaScriptA few pieces worth pulling out:
useSelectreads the editor’s current blocks reactively — every time the post content changes,blocksupdates with it.serialize( blocks )turns the block array into the samepost_contentstring WordPress stores in the database. That’s exactly the shape our ability’scontentfield expects.apiFetchhits the auto-generated endpoint. Sameinputwrapper, sameresponse.outputwe saw from the console.useDispatch( 'core/block-editor' )gives usinsertBlock. We build a paragraph, wrap it in a quote block (so the summary is visually distinct from the post content), and insert it at index 0.
Open a post, pick a length, click Generate AI Summary, and a quote block with the AI summary appears at the top.
Watching the Round Trip
Open DevTools, switch to the Network tab, filter on abilities, and click the button. You’ll see a POST to /wp-json/wp-abilities/v1/abilities/ryanwelcher/summarization/run with the { input: { content, length } } payload. That’s the endpoint WordPress generated from your schema — not one you wrote.
Notice we never validated length on the JavaScript side. The enum we declared in the ability’s input_schema means WordPress validates every request before our PHP callback runs. Pass 'gigantic' and the REST layer rejects it. Schema-first design pays off.
Gotchas
A handful of small things that will trip you up if you’re not looking for them.
- The
mcp.publicvalue must be the booleantrue.1,'1', and'true'do not opt in — the MCP Adapter checks for the boolean specifically. If your ability isn’t showing up to your MCP client, this is the first place to check. wp.apiFetchisn’t always onwindowinside the editor. When you test from DevTools, do it from a non-editor admin screen. The editor canvas runs inside an iframe and the top-levelwindowdoesn’t always havewp.apiFetchavailable.- Hook order matters for categories vs. abilities. Categories register on
wp_abilities_api_categories_init. Abilities register onwp_abilities_api_init. The first fires before the second, which is exactly what you want — but if you accidentally register the category on the wrong hook, the ability registration will fail to find it. - The
inputwrapper is not optional. Every auto-generated Abilities REST endpoint expects the payload nested underinput. If youPOSTyour fields at the top level, you’ll get anability_invalid_inputerror with no obvious hint that the wrapper is the problem. generate_text()is the only thing that fires the request.wp_ai_client_prompt()and every fluent method on the builder are lazy. If you forget the terminal call, nothing happens and you’ll wonder why your provider dashboard shows zero usage.
What MCP Gets You
The mcp.public => true line is doing more than it looks like. With the mcp-adapter package installed (it bundles with the AI plugin), every ability opted in this way becomes callable by external AI agents — Claude Desktop, Cursor, anything that speaks MCP. Same ability, same schema, same permission callback. You wrote it once for the block editor and it works in agentic contexts too.
That’s the part of WordPress 7.0 that’s easiest to undersell. The Abilities API isn’t only a tidier way to register REST endpoints. It’s a single declaration that the editor, REST clients, and AI agents all consume from. The MCP opt-in is one line.

Leave a Reply