Ryan Welcher

Developer Advocate

Building AI-Powered Admin Tools with WordPress 7.0

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';
PHP

The 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' );
PHP

Load 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' );
PHP

Categories 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' );
PHP

Two lines in meta do most of the heavy lifting:

  • show_in_rest => true auto-generates the REST endpoint at /wp-json/wp-abilities/v1/abilities/ryanwelcher/summarization/run. No register_rest_route() call. The route pattern is /<namespace>/<ability-slug>/run.
  • mcp.public => true opts 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();
}
PHP

Returning 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 );
JavaScript

Two contract details to notice — the JavaScript in the next section relies on both:

  • The payload goes inside an input wrapper. That’s the REST contract the Abilities API generates from your input_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' );
PHP

A 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 } );
JavaScript

A few pieces worth pulling out:

  • useSelect reads the editor’s current blocks reactively — every time the post content changes, blocks updates with it.
  • serialize( blocks ) turns the block array into the same post_content string WordPress stores in the database. That’s exactly the shape our ability’s content field expects.
  • apiFetch hits the auto-generated endpoint. Same input wrapper, same response.output we saw from the console.
  • useDispatch( 'core/block-editor' ) gives us insertBlock. 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.public value must be the boolean true. 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.apiFetch isn’t always on window inside 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-level window doesn’t always have wp.apiFetch available.
  • Hook order matters for categories vs. abilities. Categories register on wp_abilities_api_categories_init. Abilities register on wp_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 input wrapper is not optional. Every auto-generated Abilities REST endpoint expects the payload nested under input. If you POST your fields at the top level, you’ll get an ability_invalid_input error 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

Your email address will not be published. Required fields are marked *