Migration & Strategy

Migrating Legacy Plugins to the Abilities API

⏱ 16 min

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.

What you'll learn in this lesson
Extracting the actual business logic first
Separating "what this does" from "how HTTP or AJAX calls it."
Wrapping that logic in an ability without touching the old endpoint
Both surfaces call the same function; neither owns the logic.
Translating existing REST permission checks into permission_callback
Usually a near copy-paste, with one important caveat.
A migration order that never breaks existing consumers
What to do first, second, and last.
Prerequisites

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

Migration order that never breaks an existing consumer
1
Extract the shared function
As in Step 2, with zero behavior change, just moved code.
2
Rewrite the REST callback as a thin wrapper
It now just reads $request params and calls the shared function.
3
Deploy and verify the REST endpoint is unchanged
Same responses, same consumers, nothing looks different from outside.
4
Register the new ability as a second, independent wrapper
Calling the exact same shared function, with its own permission_callback and schema.
5
Never remove the REST route unless you control every consumer
The ability is additive, not a replacement, until you've confirmed otherwise.
// 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" ) ) );'
AJAX handlers need the same treatment, plus a nonce check that never moves

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

← Performance Optimization Under Agent Traffic Enterprise AI Strategy: Data Governance and Vendor Agnosticism →