Architecture

Designing Extensible Custom Integrations

⏱ 15 min

Every ability you’ve registered so far in this track has been a closed unit: you wrote the permission_callback, you wrote the execute_callback, and nobody else’s code was involved. That’s fine for a single plugin, but once an ability library is used by other teams, agencies, or third-party add-ons, it needs the same extensibility contract WordPress itself has trained developers to expect: filters on arguments before registration, actions after key lifecycle events, and a documented, stable surface other code can hook into without editing your files. This lesson applies that convention to abilities specifically.

What you'll learn in this lesson
Filtering ability arguments before registration
So other plugins can adjust schemas, permissions, or metadata without forking your code.
Firing actions around registration and execution
Giving extensions a reliable place to hook in.
Filtering execute_callback output
Letting extensions enrich or adjust a result without owning the whole callback.
A naming and namespace contract for extensions
So your base plugin and its add-ons never collide.
Prerequisites

This lesson assumes you’ve registered abilities before (Courses 1 and 2) and are comfortable with add_filter() / apply_filters() and add_action() / do_action() as core WordPress conventions. If those are unfamiliar, review the Plugin API handbook first.

Step 1: Filter the arguments array before you register

The single highest-leverage extension point is filtering the arguments array itself, right before it reaches wp_register_ability(). This lets an extension tighten a permission check, add a field to a schema, or attach extra meta without ever touching your plugin’s source:

// File: my-plugin.php
add_action( 'wp_abilities_api_init', function () {
	$args = array(
		'label'               => __( 'List support tickets', 'my-plugin' ),
		'description'         => __( 'Returns open support tickets for the current site.', 'my-plugin' ),
		'category'            => 'support',
		'input_schema'        => array(
			'type'       => 'object',
			'properties' => array(
				'status' => array( 'type' => 'string', 'enum' => array( 'open', 'closed' ) ),
			),
		),
		'output_schema'       => array( 'type' => 'array', 'items' => array( 'type' => 'object' ) ),
		'permission_callback' => function () {
			return current_user_can( 'edit_others_posts' );
		},
		'execute_callback'    => 'my_plugin_list_tickets',
		'meta'                => array( 'mcp' => array( 'public' => true ) ),
	);

	/**
	 * Filters the arguments for my-plugin/list-tickets before registration.
	 *
	 * @param array $args Standard wp_register_ability() argument array.
	 */
	$args = apply_filters( 'my_plugin_ability_args_list_tickets', $args );

	wp_register_ability( 'my-plugin/list-tickets', $args );
} );

An extension plugin now needs zero knowledge of your file layout to, say, restrict this ability to a stricter capability for enterprise installs:

// File: my-plugin-enterprise-addon.php
add_filter( 'my_plugin_ability_args_list_tickets', function ( $args ) {
	$args['permission_callback'] = function () {
		return current_user_can( 'manage_options' );
	};
	return $args;
} );

Step 2: Fire actions around the lifecycle, not just registration

Filters change data; actions announce moments. Give extensions both. A do_action() right after your base abilities are registered lets add-ons register abilities that depend on yours existing first, without race conditions over hook priority:

// File: my-plugin.php
add_action( 'wp_abilities_api_init', function () {
	// ... register my-plugin/list-tickets, my-plugin/get-ticket, etc.

	/**
	 * Fires after my-plugin's core abilities are registered.
	 *
	 * Extensions should register any abilities that depend on my-plugin's
	 * categories or abilities during this action, not directly on
	 * wp_abilities_api_init, to guarantee ordering.
	 */
	do_action( 'my_plugin_abilities_registered' );
}, 10 );

This is the same pattern WooCommerce uses with woocommerce_loaded, and the same reason core fires after_setup_theme separately from init: dependent code needs a guaranteed “the thing I depend on is done” signal, not a priority-number guessing game.

Step 3: Let extensions shape the result, not just the request

Filtering only the input side is half the contract. A genuinely extensible ability also filters its own output before returning it from execute_callback, so an add-on can enrich a result (add a computed field, redact a value for a lower permission tier) without re-implementing the whole query:

// File: my-plugin.php
function my_plugin_list_tickets( array $input ) {
	$tickets = my_plugin_query_tickets( $input['status'] ?? 'open' );

	/**
	 * Filters the tickets array returned by my-plugin/list-tickets.
	 *
	 * @param array $tickets Raw ticket data before returning to the caller.
	 * @param array $input   The original ability input.
	 */
	return apply_filters( 'my_plugin_list_tickets_result', $tickets, $input );
}
Extension points, mapped to purpose
Hook typeWhen it firesTypical use by a third party
Filter on args (pre-registration)Just before wp_register_ability()Tighten permission_callback, extend a schema, flip meta.mcp.public
Action after registrationEnd of your wp_abilities_api_init callbackRegister dependent abilities in the right order
Filter on execute resultInside execute_callback, before returnEnrich, redact, or reshape output for a specific integration
Filter on category argsInside wp_abilities_api_categories_initRename or re-describe a category for white-labeling

Step 4: Document a namespace contract

Namespacing (my-plugin/list-tickets) already prevents two unrelated plugins from colliding. A reusable base plugin should go one step further and document a sub-namespace convention for its own add-ons, for example requiring official extensions to prefix abilities with my-plugin-{addon}/. This is a documentation and code-review discipline, not something the Abilities API enforces for you, but it’s exactly the kind of contract that keeps a base plugin’s ability list legible once five different add-ons are installed alongside it.

Test it

Register the base ability and the enterprise add-on filter from Step 1 in two separate files (or two mu-plugins), then confirm the filter actually took effect:

wp-env run cli wp eval '
$ability = wp_get_ability( "my-plugin/list-tickets" );
var_dump( $ability->has_permission( array() ) );
'

With only the base plugin active, this reflects edit_others_posts. With the enterprise add-on’s filter also active, confirm it now requires manage_options instead, by testing as a user who has one capability but not the other.

Filters run once, at registration time

Because wp_register_ability() runs during wp_abilities_api_init and the filtered array is baked into the registered WP_Ability object, a filter added after that action has already fired for that request does nothing. Extensions must hook their filters on plugins_loaded or earlier, well before wp_abilities_api_init runs, not inside it themselves unless they also control ordering with hook priority.

Recap

Extensible abilities follow the same conventions WordPress has used for two decades: filter the arguments before you register, fire an action once registration is done so dependents can build on top, and filter your own output before returning it. None of this is Abilities API machinery, it’s just apply_filters() and do_action() applied deliberately around wp_register_ability() calls, which is exactly why it composes cleanly with everything else in this track.

Resources & further reading

Multi-Server Architectures at Scale →