Most plugins that could benefit from AI agent access didn’t start life with the
Abilities API, they started with a register_rest_route() endpoint, or worse, an
admin-ajax.php handler wired to wp_ajax_my_action. Neither of those is going away
just because you’re adding abilities. Real consumers, other plugins, a mobile app, a
scheduled cron job hitting the endpoint, are depending on the REST route or the AJAX
action continuing to work exactly as it does today. This lesson is the practical,
no-drama path from “we have a REST endpoint” to “we also have a matching ability,”
without a deprecation cycle or a breaking change in sight.
You should already be comfortable registering abilities (Course 1) and understand
permission_callback hardening (Course 4). This lesson assumes you’re migrating a
plugin you already maintain, not building one from scratch.
Step 1: Find where the actual logic lives, usually it’s tangled into the callback
Start with a typical legacy REST callback. Business logic and HTTP concerns are usually mixed together in one function:
// File: my-plugin-legacy.php (before)
add_action( 'rest_api_init', function () {
register_rest_route( 'my-plugin/v1', '/tickets', array(
'methods' => 'GET',
'callback' => 'my_plugin_rest_get_tickets',
'permission_callback' => function () {
return current_user_can( 'edit_others_posts' );
},
) );
} );
function my_plugin_rest_get_tickets( WP_REST_Request $request ) {
$status = $request->get_param( 'status' ) ?: 'open';
$tickets = get_posts( array(
'post_type' => 'ticket',
'post_status' => $status === 'open' ? 'publish' : 'draft',
'numberposts' => 20,
) );
return rest_ensure_response( array_map( function ( $t ) {
return array( 'id' => $t->ID, 'title' => $t->post_title, 'status' => $t->post_status );
}, $tickets ) );
}
Step 2: Extract a plain PHP function with no HTTP concerns at all
The fix is always the same shape: pull the actual logic into a function that takes and
returns plain PHP data, with no WP_REST_Request, no rest_ensure_response(), nothing
HTTP-specific in it at all.
// File: my-plugin-core.php (shared logic, new)
function my_plugin_get_tickets( string $status = 'open', int $limit = 20 ): array {
$tickets = get_posts( array(
'post_type' => 'ticket',
'post_status' => $status === 'open' ? 'publish' : 'draft',
'numberposts' => $limit,
) );
return array_map( function ( $t ) {
return array( 'id' => $t->ID, 'title' => $t->post_title, 'status' => $t->post_status );
}, $tickets );
}
Step 3: Make both surfaces thin wrappers around that one function
// File: my-plugin-legacy.php (REST callback, after, unchanged behavior)
function my_plugin_rest_get_tickets( WP_REST_Request $request ) {
$tickets = my_plugin_get_tickets(
$request->get_param( 'status' ) ?: 'open',
(int) ( $request->get_param( 'limit' ) ?: 20 )
);
return rest_ensure_response( $tickets );
}
// File: my-plugin-abilities.php (new)
add_action( 'wp_abilities_api_init', function () {
wp_register_ability( 'my-plugin/get-tickets', array(
'label' => __( 'Get tickets', 'my-plugin' ),
'description' => __( 'Returns support tickets filtered by status.', 'my-plugin' ),
'category' => 'support',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'status' => array( 'type' => 'string', 'enum' => array( 'open', 'closed' ) ),
'limit' => array( 'type' => 'integer', 'default' => 20 ),
),
),
'output_schema' => array( 'type' => 'array', 'items' => array( 'type' => 'object' ) ),
'permission_callback' => function () {
return current_user_can( 'edit_others_posts' ); // same check as the REST route
},
'execute_callback' => function ( array $input ) {
return my_plugin_get_tickets( $input['status'] ?? 'open', $input['limit'] ?? 20 );
},
'meta' => array( 'mcp' => array( 'public' => false ) ),
) );
} );
Notice the two surfaces never call each other, they both call
my_plugin_get_tickets(). That’s deliberate: the REST route’s behavior is now
completely decoupled from whatever you do to the ability later, and vice versa.
Step 4: Watch for one real gap when translating permission checks
A REST permission_callback and an ability permission_callback look identical, and
usually the check can be copy-pasted directly, but confirm it doesn’t implicitly rely
on REST-specific context, a nonce check via wp_verify_nonce() tied to a specific
admin screen, or a $request parameter the ability’s permission_callback won’t
receive in the same shape. Abilities can accept the input array as a parameter to
permission_callback too, but there’s no WP_REST_Request object; if your old
permission logic reached into request headers or route parameters directly, rewrite
that part explicitly rather than assuming it transfers unchanged.
Test it
After the refactor, hit the original REST endpoint exactly as its existing consumers do, and separately call the new ability, confirming both return identical data for the same logical input:
curl -s https://example.com/wp-json/my-plugin/v1/tickets?status=open | jq .
wp-env run cli wp eval 'var_dump( wp_get_ability( "my-plugin/get-tickets" )->execute( array( "status" => "open" ) ) );'
admin-ajax.php handlers almost always check check_ajax_referer() for a nonce as
part of the handler itself. When extracting shared logic from an AJAX action, leave
that nonce check inside the AJAX-specific wrapper, not the shared function, since an
ability call has no admin-ajax nonce to check at all and would fail it every time.
Recap
Migrating a legacy endpoint to the Abilities API is a refactor, not a rewrite: extract the actual logic into a plain function with no HTTP or AJAX concerns, make the existing REST or AJAX callback a thin wrapper around it, verify nothing changed for existing consumers, then add the ability as a second, independent thin wrapper around the same function. The REST route doesn’t get removed just because the ability exists; it gets removed later, deliberately, if and when you actually control every consumer of it.
Resources & further reading
- REST API handbook, developer.wordpress.org
- Abilities API reference, developer.wordpress.org
- AJAX in the admin, Plugin handbook, developer.wordpress.org