WordPress 7.0 shipped the Abilities API, and we covered what the JavaScript client lets you build. Registering an ability is straightforward. What was missing was everything around it.
An ability is a callable unit of functionality that something else invokes. When that something else is your own code, the absence of hooks is a mild inconvenience. When it is an AI agent deciding on its own which abilities to call and with what input, the absence of hooks is a governance problem. You had no place to stand between the caller and the callback.
WordPress 7.1 adds four filters to the execution pipeline. Together they give you a seam at every point that matters: before anything runs, after input is normalised, after the permission check, and after the callback returns. It is a quieter change than the ones that got attention this cycle, and for anyone exposing abilities to agents it is the one that matters most.
The four filters
Filter | Fires | Filters |
|---|---|---|
| Start of |
|
| Inside |
|
| Inside |
|
| After the registered callback, before output validation |
|
All four receive the ability name and the WP_Ability instance, and all but the first also receive the input. That signature matters more than it looks: because every filter knows which ability it is looking at, one handler can govern every ability on the site rather than needing to be wired per ability.
The two existing actions, wp_before_execute_ability and wp_after_execute_ability, are unchanged. Actions observe. Filters intervene. You will usually want both.
Short-circuiting the whole pipeline
wp_pre_execute_ability runs first and can end the call before anything else happens. It follows the convention you already know from pre_get_posts and friends: return the value you were given and execution continues, return anything else and the rest of the pipeline is skipped entirely.
add_filter( 'wp_pre_execute_ability', function ( $pre, $ability_name, $input, $ability ) {
// Kill switch: stop every ability during an incident, without
// deactivating the plugins that registered them.
if ( get_option( 'my_abilities_paused' ) ) {
return new WP_Error(
'abilities_paused',
__( 'Abilities are temporarily disabled.', 'my-plugin' ),
array( 'status' => 503 )
);
}
return $pre;
}, 10, 4 );That is the pattern worth having ready before you need it. If an agent integration starts behaving badly at two in the morning, a single option flip stops every ability at once, and it stops them at the top of the pipeline rather than somewhere in the middle of a callback that may have already written to the database.
The same hook is the natural place for a cache. An ability that resolves an expensive read and is called repeatedly by an agent walking your content does not need to run every time.
add_filter( 'wp_pre_execute_ability', function ( $pre, $ability_name, $input, $ability ) {
if ( 'my-plugin/expensive-report' !== $ability_name ) {
return $pre;
}
$key = 'ability_' . md5( $ability_name . wp_json_encode( $input ) );
$cached = get_transient( $key );
return false === $cached ? $pre : $cached;
}, 10, 4 );Note the guard on the ability name in the first line. Every one of these filters fires for every ability, so an unguarded handler is a handler that runs on calls you never intended it to touch.
Enriching input before validation
wp_ability_normalize_input fires after schema defaults have been applied but before validation. That ordering is the useful part: whatever you add here still has to satisfy the schema, so you cannot use it to smuggle in a value the ability was never designed to accept.
add_filter( 'wp_ability_normalize_input', function ( $input, $ability_name, $ability ) {
if ( 'my-plugin/draft-post' !== $ability_name ) {
return $input;
}
// The caller should not have to know the house style. Attach it.
if ( empty( $input['tone'] ) ) {
$input['tone'] = get_option( 'my_editorial_tone', 'plain' );
}
return $input;
}, 10, 3 );This is where site-specific context belongs. An agent calling a content ability does not know your editorial conventions, your default author, or which taxonomy you file things under. Rather than teaching every caller, attach the context once, here, and let the ability stay generic.
The one that matters most: overriding the permission result
wp_ability_permission_result runs after the ability’s own permission callback and can change the answer. It can return true, false, or a WP_Error.
This is the filter that earns the release, because the Abilities API changes who the caller is. A capability check answers the question is this user allowed to do this. It does not answer should this be happening right now, at this rate, from this direction. When an agent is acting with a user’s identity, those are different questions.
add_filter( 'wp_ability_permission_result', function ( $permission, $ability_name, $input, $ability ) {
// Already denied by the ability's own callback. Leave it denied.
if ( true !== $permission ) {
return $permission;
}
// Destructive abilities require a human in the loop, whatever
// capabilities the acting user happens to hold.
$destructive = array( 'my-plugin/delete-posts', 'my-plugin/purge-users' );
if ( in_array( $ability_name, $destructive, true ) && wp_is_agent_request() ) {
return new WP_Error(
'requires_human',
__( 'This ability cannot be invoked by an automated caller.', 'my-plugin' ),
array( 'status' => 403 )
);
}
return $permission;
}, 10, 4 );Two things about that snippet. The first check is deliberate: a permission filter that can turn a denial into an approval is a permission filter that will eventually be used to do exactly that by mistake. Returning early on anything that is not already true means this handler can only ever tighten, never loosen.
The second is that wp_is_agent_request() is a stand-in. There is no core function by that name; you would implement the check yourself against whatever signal your integration provides, whether that is an application password, a specific REST route, or a header your agent layer sets. The shape of the pattern is the point, not that particular call.
Transforming or recovering the result
wp_ability_execute_result fires after the registered callback and before output validation. Because it sits before validation, anything you return still has to match the output schema.
The obvious use is redaction. An ability that returns user records to an agent probably should not be returning email addresses.
add_filter( 'wp_ability_execute_result', function ( $result, $ability_name, $input, $ability ) {
if ( 'my-plugin/list-members' !== $ability_name || is_wp_error( $result ) ) {
return $result;
}
foreach ( $result as &$member ) {
unset( $member['email'], $member['ip'] );
}
return $result;
}, 10, 4 );The less obvious use is recovery. A callback that fails for a transient reason can be turned into a graceful degradation rather than an error the agent has to reason about.
add_filter( 'wp_ability_execute_result', function ( $result, $ability_name, $input, $ability ) {
if ( ! is_wp_error( $result ) || 'my-plugin/fetch-stats' !== $ability_name ) {
return $result;
}
// Upstream is down. Return the last good figures rather than an error.
$fallback = get_option( 'my_last_good_stats' );
return $fallback ? $fallback : $result;
}, 10, 4 );Use that one sparingly. Swallowing errors so an agent never sees them is a good way to end up with an agent confidently acting on stale data. Recover where the stale answer is genuinely better than no answer, and let it fail where it is not.
The pattern most sites actually need: an audit trail
If you take one thing from this release, take this. The moment abilities are reachable by something other than your own code, you want a record of what was called, by whom, with what, and what came back.
Actions handle the observation and filters handle the outcome, so a complete trail uses both.
add_action( 'wp_before_execute_ability', function ( $ability_name, $input ) {
do_action( 'my_audit', array(
'event' => 'ability.start',
'ability' => $ability_name,
'user' => get_current_user_id(),
'input' => $input,
'time' => microtime( true ),
) );
}, 10, 2 );
add_filter( 'wp_ability_permission_result', function ( $permission, $ability_name, $input, $ability ) {
if ( true !== $permission ) {
do_action( 'my_audit', array(
'event' => 'ability.denied',
'ability' => $ability_name,
'user' => get_current_user_id(),
'reason' => is_wp_error( $permission ) ? $permission->get_error_code() : 'denied',
) );
}
return $permission;
}, 999, 4 );Note the priority of 999 on the permission logger. You want to record the answer that was actually used, which means running after everything else that might have changed it.
Denials are the entries worth keeping. A run of them from one caller is the clearest early signal that an integration is misconfigured, or that something is probing what it is allowed to reach.
Rate limiting a caller
Rate limiting sits naturally in the permission filter, because a rate-limited call is a call that should be denied rather than one that should be silently skipped.
add_filter( 'wp_ability_permission_result', function ( $permission, $ability_name, $input, $ability ) {
if ( true !== $permission ) {
return $permission;
}
$key = 'ability_rate_' . get_current_user_id();
$count = (int) get_transient( $key );
if ( $count >= 60 ) {
return new WP_Error(
'rate_limited',
__( 'Too many ability calls. Try again shortly.', 'my-plugin' ),
array( 'status' => 429 )
);
}
set_transient( $key, $count + 1, MINUTE_IN_SECONDS );
return $permission;
}, 20, 4 );A transient counter is the crude version and it is fine for most sites. If abilities become a serious surface for you, move the counter somewhere that does not depend on the object cache surviving, because a cache flush should not reset a rate limit.
What these filters do not give you
Worth being clear about the boundaries, because it is easy to read a set of hooks as more than it is.
- They are not a sandbox. The callback still runs with full site privileges when it runs at all. You are deciding whether it runs and what comes back, not constraining what it can touch while running.
- They do not authenticate anything. Knowing that a call came from an agent rather than a person is your problem to solve upstream. The permission filter lets you act on that knowledge; it does not supply it.
- They do not validate for you. Input validation still happens against the schema, and output validation still happens after your result filter. Returning something the schema rejects is your bug, not a caught error.
- They are global by default. Every handler fires for every ability. The guard clause on the ability name is not optional style, it is the difference between a targeted filter and one that quietly runs on everything.
Frequently asked questions
Do I need to change existing abilities to use these?
No. The filters wrap the execution pipeline, so they apply to every registered ability including ones you did not write. That is the main reason they are useful: you can govern a third-party plugin’s abilities without touching that plugin.
What is the difference between the actions and the filters?
wp_before_execute_ability and wp_after_execute_ability observe: they run at those points and cannot change the outcome. The four filters intervene: each one can change the value passing through it. Logging belongs in the actions, policy belongs in the filters.
Can I use the permission filter to grant access rather than deny it?
Technically yes, since returning true overrides a denial. Do not. A filter that can widen access is one that will eventually widen it by accident, and the failure is silent. Return early on anything that is not already true so your handler can only tighten.
Where should I put a kill switch?
wp_pre_execute_ability, because it runs before normalisation, validation and the permission check. Stopping there means nothing downstream executes at all, which is what you want when you are stopping something because you do not yet know what it is doing.
Does short-circuiting skip the permission check?
Yes, and that is the thing to be careful about. Returning a value from wp_pre_execute_ability bypasses the remaining pipeline, permission check included. If you use it for caching, make sure the cache key accounts for who is asking, or you will serve one user’s result to another.
Do these hooks fire for abilities called from JavaScript?
The filters sit in the server-side execution path, so any call that reaches WP_Ability::execute() passes through them regardless of what initiated it. A client-side call that resolves entirely in the browser without hitting the server would not, so treat the seam as server-side and design accordingly.
Is there a performance cost to adding these?
Negligible for the filters themselves. The cost is in what you put in them. A permission filter that runs a database query on every ability call, on a site where an agent calls abilities hundreds of times, is a cost you will notice. Keep the handlers cheap, and cache the expensive checks.
What else should I be testing for 7.1?
The enforced iframed editor is the change most likely to break existing custom code, and it fails quietly. We covered it alongside the React 19 upgrade being pulled from the release, which is worth reading if you planned work around that.
The bottom line
Four filters, one per meaningful point in the execution pipeline: wp_pre_execute_ability to stop or short-circuit a call, wp_ability_normalize_input to attach context the caller does not have, wp_ability_permission_result to tighten who may do what, and wp_ability_execute_result to redact or recover what comes back. Paired with the existing before and after actions, that is enough to build an audit trail, a rate limit and a kill switch without modifying a single registered ability.
Build the audit trail first. Everything else on that list is a decision you can make later, but only if you can see what has been happening.





No comments yet