Skip to content

Abilities API: Add an eligibility callback for situational relevance - #13356

Draft
gziolo wants to merge 2 commits into
WordPress:trunkfrom
gziolo:add/abilities-eligibility-callback
Draft

gziolo wants to merge 2 commits into
WordPress:trunkfrom
gziolo:add/abilities-eligibility-callback

Conversation

@gziolo

@gziolo gziolo commented Sep 1, 2026 •

Copy link
Copy Markdown
Member

What

This is a draft to explore the design in the open.

An ability can declare an eligibility_callback when it is registered. The callback receives an array that describes where the caller is. It returns true or false to say if the ability is useful there. wp_get_abilities() accepts a new eligibility_context argument. Abilities whose callback returns false are left out. The REST collection accepts the same data through an eligibility_context query parameter.

The first user of this feature is a WebMCP adapter (WordPress/ai#448). It registers tools for each admin screen and each frontend page. A site with hundreds of abilities can then expose only the few that matter on the current page.

Contract

Three things stay separate:

  • Permission: can the current user run this? Checked by permission_callback when the ability runs.
  • Exposure: does the site owner allow this ability on this surface? See Unifiied Abilities exposure controls ai#354.
  • Eligibility: does the ability author think it is useful in this context? This PR.

Eligibility is not a security check. The callback runs only when listing abilities. It never runs when an ability executes. It never runs on the single ability REST route. The context comes from the caller and is not verified. Nothing security related may depend on it.

The result without a context is the upper bound. With no context, callbacks are not called and every ability is included. Context keys can only remove abilities from a result. They can never add more. This keeps the MCP adapter's tools/list complete, because it passes no context.

An ability without a callback is always included. A callback that returns something that is not a boolean is treated as true.

Core does not define the context keys. Callers and ability authors agree on them. Plugins should prefix their own keys, like plugin-slug/key. The REST parameter declares no properties, so values arrive as strings. A plugin can declare typed keys with the existing rest_abilities_collection_params filter.

New API

  • eligibility_callback registration argument. Must be callable.
  • WP_Ability::is_eligible( array $eligibility_context = array() ): bool, with a wp_ability_eligibility_result filter. It follows the same pattern as the other lifecycle filters.
  • eligibility_context argument for wp_get_abilities(). It runs in the same loop, after the category, namespace, and meta filters and before item_include_callback. The wp_get_abilities_item_include filter can see it through $args.
  • eligibility_context object parameter on the wp-abilities/v1/abilities route. Bracket syntax works: eligibility_context[post_type]=product.

Open questions

  • Should an empty context still call the callbacks? Right now it does not. Evaluation starts only when the caller passes at least one key.
  • The Trac ticket does not exist yet. The new tests will get @ticket annotations once it does.
  • The JavaScript side (@wordpress/abilities and @wordpress/core-abilities) lives in the Gutenberg repository and will follow separately.

🤖 Generated with Claude Code

An ability can declare an eligibility_callback at registration. The
callback receives an associative array describing the caller's usage
context and returns whether the ability applies there.
wp_get_abilities() accepts an eligibility_context argument and drops
abilities whose callback returns false. The abilities REST collection
accepts the same context through an eligibility_context query parameter.
Eligibility is consulted only when listing abilities, never on execute,
and it is not a security boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@JasonTheAdams

Copy link
Copy Markdown
Member

I'm excited for this! I think this is going to be useful in contexts like limiting MCP tools by Agent user capability, as well as surfacing features to users within a UI. For example, only showing a refund button if the user can use the refund_order ability — it's a nice abstraction around what may be a bunch of capability checks.

I've imagined the signature to be something like:

/**
 * @param WP_User $user a specified user to check for, defaults to current user
 * @param array $input optional set of inputs as would be used when calling the ability, in case this changes permissions
 */
public function is_eligible(WP_User $user = null, array $input = null): bool;

I could go either way on the order of parameters. Note, that I'm calling it input to more directly connect this parameter to the ability input. I felt like "eligibility_context" sounds like something new and distinct.

Ideally, this would do a permissions check by default, with the option for a specific callback as you've made in this PR. I don't think we want to default to true without the callback, though; we can always at least check the permissions. This may require some adjusting to how permissions work, too.

@gziolo

gziolo commented Sep 9, 2026 •

Copy link
Copy Markdown
Member Author

Thanks, Jason! I want to make sure we are on the same page before iterating on the design.

The way I see it, the existing has_permission( $input ) answers "can the current user run this ability with this input". It is tied to execution. is_eligible() answers "is this ability relevant on the surface the caller is on". It runs only when listing, with no intent to execute. That is why in my mind the callback receives a context, not an input. The name is open for discussion.

A few questions:

  • Would has_permission( $input ) cover the refund button example, or do you see a gap there?
  • How do you picture $user reaching the callbacks? Today they use current_user_can(), which reads the global current user.
  • With a permission check as the default at list time, what should happen for abilities whose permission callback depends on the input?
  • Could you share a few usage scenarios you have in mind? That would help me see whether they fit the eligibility idea, or the permission and exposure layers instead.

@JasonTheAdams

Copy link
Copy Markdown
Member

The more I think about this, @gziolo , the more I feel like improving has_permission is the better path at this time.

We have two distinct scenarios:

  • Checking whether X user has Y capabilities to execute Z ability
  • Checking whether Z ability is usable in some context

I think the most common scenarios is the former, and we should do things like allow for an optional WP_User (maybe role?) second parameter, defaulting to the current user.

This PR addresses the latter, but at this point it just feels too vague to me. Permission checks are tied to a concrete and well defined capability system. The context check is some kind of callback doing some kind of check against some kind of data. If we used WP_Screen or something, that would help, but I'm also wondering why server-side Abilities would even need to be filtered in this sort of way. Front-end Abilities, I think, make more sense that they shouldn't be loaded on every page, but that's not what we're doing here.

If the main use case here is WebMCP, I think we should consider a different approach for this that's tied to something more concrete within WordPress.

@gziolo

gziolo commented Oct 15, 2026 •

Copy link
Copy Markdown
Member Author

Thanks for thinking this through, @JasonTheAdams. I agree that checking whether a user can use an ability matters more right now. I looked at doing it through check_permissions() and found two problems.

A WP_User parameter doesn't fit how permission callbacks work. In WordPress, capability checks are tied to the current user through a global. Every permission callback in core uses current_user_can(), and plugin callbacks will too. A $user parameter would only work if every callback moved to user_can( $user, ... ). Any callback that didn't move would quietly check the wrong user. When a caller needs to act as another user, it can already switch with wp_set_current_user() first. For example, an MCP adapter that authenticates as an agent user does this anyway.

check_permissions() is bound to the input. It passes the real input to the callback, so the callback can answer questions like "can this user edit this post?". A listing mode with no input would need a new flag. Every callback would then have to handle a third answer ("maybe"), which it can't express today. We can't use null as that signal either, because null is already valid input for abilities without an input schema.

Proposal: declare required capabilities when registering an ability

This builds on an idea @galatanovidiu shared in WordPress/abilities-api#62 (comment). He suggested a simple capability-based pre-filter. With it, abilities the user can't access are filtered out before they are shown to AI agents, and their permission callbacks don't have to run. This proposal turns that idea into a concrete API.

Abilities can declare the capabilities a user needs to run them:

wp_register_ability(
	'core/get-site-info',
	array(
		'label'                 => __( 'Get Site Information' ),
		'description'           => __( 'Returns basic information about the site.' ),
		'category'              => 'site',
		'required_capabilities' => array( 'manage_options' ),
		'execute_callback'      => 'wp_abilities_get_site_info',
	)
);

Registration

  • required_capabilities is a top-level argument, like permission_callback. Core enforces it, so it doesn't belong in meta, which holds hints.
  • It must be a list of non-empty strings.
  • When required_capabilities is set, permission_callback becomes optional. Most core abilities only check one capability, so they won't need a closure anymore.
  • An empty list doesn't replace permission_callback. Public abilities still need 'permission_callback' => '__return_true', so that "open to everyone" stays an explicit choice.

Execution

check_permissions( $input ) works in this order:

  1. If required_capabilities is set, it checks each capability with current_user_can(). If one is missing, the check fails right away.
  2. If all capabilities pass and there is a permission_callback, it runs with $input, as it does today.
  3. The wp_ability_permission_result filter runs on the final result, including a failure from step 1. This keeps filters like temporary permission elevation working.

So required_capabilities is the first check, and permission_callback adds rules that depend on the input.

Listing

wp_get_abilities() gets a new argument, for example 'current_user_can' => true. It leaves out abilities that declare capabilities the current user doesn't have.

  • Abilities without required_capabilities stay in the result. Their permission may depend on the input, so we don't hide them.
  • It runs with the other declarative filters (category, namespace, meta), before item_include_callback.
  • The REST list route gets a matching query parameter.
  • The REST response includes required_capabilities, so JavaScript clients can hide UI for abilities the user can't run.

This answers "can the current user use this ability?" when listing abilities, without calling any permission callbacks. Listing, execution and the client all read one list, so they can't get out of sync.

This proposal doesn't cover every use case. Abilities whose permission depends on the input will still be listed, and the final check still happens at execution. But it closes an obvious gap. Today we list all abilities, even the ones the current user can't run.

@gziolo

gziolo commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

Opened a new ticket for the proposal: https://core.trac.wordpress.org/ticket/66325. PR is ready for review:

@gziolo gziolo self-assigned this Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants