Back in 2011, I wrote about wp_ajax – the WordPress hook to handle ajax requests. The example in that post was simple: click a link, send a request to admin-ajax.php, and alert the site description. That pattern has served us well for fifteen years.
WordPress 6.9 shipped something that made me smile when I first explored it. There is a new Abilities API, and one of the first abilities core registered is core/get-site-info – which returns, among other things, the site description. The exact thing my old tutorial did with an ajax handler is now a first-class, discoverable unit of functionality in core.
So this post is the natural sequel. We will take a typical wp_ajax handler and migrate it to an ability, step by step, without breaking any existing code. Everything below was written and tested on WordPress 7.0.2 – the code and the error responses are real output, not guesses.
Table of content
- What is an ability
- Our starting point – a wp_ajax handler
- Step 1: Extract the actual work into a function
- Step 2: Register the ability
- What you get for free
- Running abilities from PHP
- Do you delete the wp_ajax handler ?
- Where this is heading
What is an ability ?
An ability is a named unit of functionality with a description, a JSON Schema for its input and output, a permission callback and an execute callback. You register it once, and WordPress makes it discoverable – other plugins can find it, the REST API can list and run it, WP-CLI can call it, and AI clients can use it as a tool through MCP.
Think about what a wp_ajax handler really is: a function reachable over one URL, with WordPress fully loaded. The Abilities API keeps that idea and adds the parts we always had to hand-roll ourselves – input validation, permission checks, and a machine-readable description of what the thing actually does. That last part is what makes abilities interesting in 2026: an AI agent can read the description and schemas and figure out how to call your function. Nobody could ever discover a wp_ajax action without reading your source code.
The Abilities API needs WordPress 6.9 or above. If your plugin supports older versions, guard the registration with function_exists( 'wp_register_ability' ).
Our starting point – a wp_ajax handler
Here is the kind of handler many plugins carry around. It returns the number of published and draft entries for a post type. Classic 2011 style – read $_POST, sanitize by hand, check capability by hand, send json.
add_action( 'wp_ajax_w4dev_post_stats', 'w4dev_post_stats_ajax' );
function w4dev_post_stats_ajax() {
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( array( 'error' => 'You are not allowed to do this.' ) );
}
$post_type = isset( $_POST['post_type'] ) ? sanitize_key( $_POST['post_type'] ) : 'post';
if ( ! post_type_exists( $post_type ) ) {
wp_send_json_error( array( 'error' => 'Unknown post type.' ) );
}
$counts = wp_count_posts( $post_type );
wp_send_json_success( array(
'published' => (int) $counts->publish,
'draft' => (int) $counts->draft,
) );
}
It works, but everything about it is invisible. The action name, the accepted parameters, the shape of the response – all of it lives only in your head and your source code.
Step 1: Extract the actual work into a function
The migration trick is simple: pull the real work out of the ajax handler into a plain function that takes plain arguments and returns plain data (or a WP_Error). No $_POST, no wp_send_json_*.
function w4dev_get_post_stats( $post_type ) {
if ( ! post_type_exists( $post_type ) ) {
return new WP_Error( 'unknown_post_type', 'Unknown post type: ' . $post_type );
}
$counts = wp_count_posts( $post_type );
return array(
'published' => (int) $counts->publish,
'draft' => (int) $counts->draft,
);
}
The ajax handler now shrinks down to transport work only – and the same function will power the ability. One source of truth.
add_action( 'wp_ajax_w4dev_post_stats', 'w4dev_post_stats_ajax' );
function w4dev_post_stats_ajax() {
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( array( 'error' => 'You are not allowed to do this.' ) );
}
$post_type = isset( $_POST['post_type'] ) ? sanitize_key( $_POST['post_type'] ) : 'post';
$stats = w4dev_get_post_stats( $post_type );
if ( is_wp_error( $stats ) ) {
wp_send_json_error( array( 'error' => $stats->get_error_message() ) );
}
wp_send_json_success( $stats );
}
Step 2: Register the ability
Abilities must be registered on the wp_abilities_api_init hook – core enforces this with a _doing_it_wrong notice if you try anywhere else. Every ability also needs a category, and the category has to be registered first, on its own hook wp_abilities_api_categories_init.
add_action( 'wp_abilities_api_categories_init', 'w4dev_register_ability_category' );
function w4dev_register_ability_category() {
wp_register_ability_category(
'w4dev',
array(
'label' => 'W4dev',
'description' => 'Abilities provided by the w4dev plugin.',
)
);
}
add_action( 'wp_abilities_api_init', 'w4dev_register_abilities' );
function w4dev_register_abilities() {
wp_register_ability(
'w4dev/post-stats',
array(
'label' => 'Post Stats',
'description' => 'Returns the number of published and draft entries for a given post type.',
'category' => 'w4dev',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'post_type' => array(
'type' => 'string',
'description' => 'Post type name, ex: post, page.',
'default' => 'post',
),
),
'additionalProperties' => false,
'default' => array(),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'published' => array( 'type' => 'integer' ),
'draft' => array( 'type' => 'integer' ),
),
),
'execute_callback' => function ( $input ) {
$post_type = isset( $input['post_type'] ) ? $input['post_type'] : 'post';
return w4dev_get_post_stats( $post_type );
},
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'meta' => array(
'show_in_rest' => true,
'annotations' => array(
'readonly' => true,
'idempotent' => true,
),
),
)
);
}
A few things worth knowing here, because most write-ups get them wrong or skip them:
permission_callbackis required, not optional. Core will not register the ability without it. This is a good decision – with wp_ajax, forgetting the capability check was the classic security bug.- The ability name must be
prefix/ability-name, lowercase letters, numbers and dashes only. show_in_restdefaults to false. Without it your ability is PHP-only.- The top level
'default' => array()in the input schema matters. Without it, calling the ability with no input at all fails validation with “input is not of type object”. Core’s owncore/get-site-infoability sets this default too.
What you get for free
1. Input validation. Remember the sanitize_key() and isset() dance in the ajax handler ? Gone. WordPress validates the input against your schema before your callback ever runs. Pass an integer as post_type and this comes back:
{"code":"ability_invalid_input","message":"Ability \"w4dev/post-stats\" has invalid input. Reason: input[post_type] is not of type string.","data":{"status":400}}
And because we set additionalProperties to false, unknown parameters are rejected too: “foo is not a valid property of Object”.
2. Permission enforcement. Call it without the right capability and the callback never runs:
{"code":"rest_ability_cannot_execute","message":"Sorry, you are not allowed to execute this ability.","data":{"status":401}}
3. A REST endpoint. With show_in_rest enabled, the ability is listed at /wp-json/wp-abilities/v1/abilities and executable at its own run route. Authentication works with a regular application password:
curl "https://example.com/wp-json/wp-abilities/v1/abilities/w4dev/post-stats/run?input[post_type]=page" \
--user "yourname:xxxx xxxx xxxx xxxx xxxx xxxx"
{"published":18,"draft":2}
4. The annotations actually do something. We marked the ability readonly. That is not just documentation – the REST controller refuses POST requests for read-only abilities and answers “Read-only abilities require GET method” with a 405. Write-style abilities run over POST instead. These annotations (readonly, destructive, idempotent) are also exactly what AI clients read to decide how carefully they should treat a tool.
Running abilities from PHP
Abilities are not only for remote clients. Any plugin can discover and execute them:
$ability = wp_get_ability( 'w4dev/post-stats' );
if ( $ability ) {
$result = $ability->execute( array( 'post_type' => 'page' ) );
if ( ! is_wp_error( $result ) ) {
print_r( $result ); // Array ( [published] => 18 [draft] => 2 )
}
}
The same validation and permission rules apply – execute() checks the current user, validates the input against the schema, and returns a WP_Error when something is off. wp_get_abilities() returns everything registered on the site, which is how one plugin can offer functionality to another without either knowing about the other in advance.
Do you delete the wp_ajax handler ?
No. That is the nice part of this migration – the ajax handler and the ability both call w4dev_get_post_stats(), so your existing frontend JavaScript keeps working exactly as before. You are not replacing an endpoint, you are giving the same functionality a second, self-describing front door.
My rule of thumb: form submissions and UI interactions from your own theme or plugin JS are fine staying on admin-ajax or the REST API. Register an ability when the functionality is a unit of work that something else might want to call – another plugin, WP-CLI, or an AI agent.
Where this is heading
The Abilities API is the foundation WordPress is building its AI story on. WordPress 7.0 added a client-side abilities registry for JavaScript, and the official MCP Adapter exposes your abilities as MCP tools, so clients like Claude or Cursor can discover and call them – the ability you registered above is already everything the adapter needs. Connecting an AI client to your site safely, without handing it an administrator key, deserves its own article. That one is coming next.

Leave a Reply