Skip to content

Conversation

emdashcodes
Copy link
Contributor

@emdashcodes emdashcodes commented Sep 11, 2025

This PR follows up on #60 and introduces client-side only abilities. This allows for new integrations that deal with things like UI elements, navigation, and client-side data (such as the block editor). In addition to our AI efforts, it's also meant as a way that the command palette can execute client-side abilities in the future.

It adds two new methods to manage the client-only abilities registerAbility and unregisterAbility.

registerAbility takes the same shape as wp_register_ability in addition to a client side callback function. A callback can take the provided input_schema, do an action, and return the provided output_schema.

Here is a simple example:

registerAbility( {
	name: 'demo/navigate-admin',
	label: 'Navigate to Admin Page',
	description: 'Navigates to a specific WordPress admin page',
	location: 'client',
	meta: {
		type: 'tool',
	},
	input_schema: {
		type: 'object',
		properties: {
			page: {
				type: 'string',
				description: 'The admin page to navigate to (e.g., "users.php", "options-general.php")',
			},
			params: {
				type: 'object',
				description: 'Optional query parameters',
				additionalProperties: true,
			},
		},
		required: [ 'page' ],
	},
	output_schema: {
		type: 'object',
		properties: {
			success: { type: 'boolean' },
			url: { type: 'string' },
		},
		required: [ 'success', 'url' ],
	},
	callback: async function( input ) {
		const adminUrl = clientAbilitiesDemo.adminUrl || '/wp-admin/';
		let url = adminUrl + input.page;

		// Add query parameters if provided
		if ( input.params ) {
			const params = new URLSearchParams( input.params );
			url += '?' + params.toString();
		}

		// Navigate to the URL
		window.location.href = url;

		return {
			success: true,
			url: url,
		};
	},
} );

unregisterAbility is a convenience method for removing client-side only abilities.

Client-side abilities are saved in the store alongside server-side ones. They are marked specifically with a location property which is used to either call the callback or call the REST endpoint.

The input and output schemas are validated using Ajv and the ajv-formats plugin.

I have configured Ajv to support the intersection of rules that JSON Schema draft-04, WordPress (a subset of JSON Schema draft-04), and providers like OpenAI and Anthropic support.

Ajv is already in use by Gutenberg so it is already part of the ecosystem.

⚠️ Note: Tests and CI setup have been added in #70

Testing

cd packages/client

# Install dependencies
npm install

# Build the package
npm run build

I have created a dummy plugin that registers a few different abilities as an example: client-abilities-demo.zip

Open the developer console and paste the following:

await wp.abilities.listAbilities()

You should see the client side abilities (and any other server side abilities you registered) listed.

Paste in the following:

await wp.abilities.executeAbility('demo/get-current-user')

See that you get a simple user response object back.

Paste in the following:

await wp.abilities.executeAbility('demo/navigate-admin', { 
	page: 'users.php' 
});

See that you are redirected to the users.php screen.

Paste in the following:

wp.abilities.executeAbility('demo/show-notification', { 
	message: 'Hello from client ability!',
	type: 'success'
});

See that a UI notice briefly shows on the user page.

Now we can test registering and unregistering our own ability:

wp.abilities.registerAbility({
    name: 'demo/console-log',
    label: 'Console Logger',
    description: 'Logs messages to the browser console with different levels',
    location: 'client',
    input_schema: {
        type: 'object',
        properties: {
            message: {
                type: 'string',
                description: 'Message to log',
            },
            level: {
                type: 'string',
                enum: ['log', 'info', 'warn', 'error', 'debug'],
                description: 'Console log level',
                default: 'log'
            },
            data: {
                description: 'Additional data to log',
            }
        },
        required: ['message']
    },
    output_schema: {
        type: 'object',
        properties: {
            logged: { type: 'boolean' },
            timestamp: { type: 'string' },
            message: { type: 'string' }
        }
    },
    callback: async ({ message, level = 'log', data }) => {
        const timestamp = new Date().toISOString();
        const prefix = `[WP Ability ${timestamp}]`;

        switch (level) {
            case 'info':
                console.info(prefix, message, data || '');
                break;
            case 'warn':
                console.warn(prefix, message, data || '');
                break;
            case 'error':
                console.error(prefix, message, data || '');
                break;
            case 'debug':
                console.debug(prefix, message, data || '');
                break;
            default:
                console.log(prefix, message, data || '');
        }

        return {
            logged: true,
            timestamp: timestamp,
            message: message
        };
    }
});

Now test the ability:

// Simple log
await wp.abilities.executeAbility('demo/console-log', {
    message: 'Hello from WordPress abilities!'
});

// Warning with data
await wp.abilities.executeAbility('demo/console-log', {
    message: 'Low memory warning',
    level: 'warn',
    data: { available: '100MB', required: '500MB' }
});

// Error log
await wp.abilities.executeAbility('demo/console-log', {
    message: 'Failed to save settings',
    level: 'error',
    data: { code: 'NETWORK_ERROR', retry: true }
});

See that the various responses and different console types are executed.

To test the validation, you can change the return type and see what happens if you try to return a different type or something invalid against the schema.

Finally, you can unregister the client-side ability: wp.abilities.unregisterAbility('demo/console-log');. Trying to execute it again will throw an error.

Next Steps (not addressed in this PR)

Tests & CI setup for the whole client package have been added in #70

@emdashcodes emdashcodes self-assigned this Sep 11, 2025
Copy link

github-actions bot commented Sep 11, 2025

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: emdashcodes <[email protected]>
Co-authored-by: gziolo <[email protected]>
Co-authored-by: swissspidy <[email protected]>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

Copy link

codecov bot commented Sep 11, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.29%. Comparing base (27c173e) to head (082dfdf).

Additional details and impacted files
@@                  Coverage Diff                  @@
##             add/client-library      #69   +/-   ##
=====================================================
  Coverage                 79.29%   79.29%           
  Complexity                   96       96           
=====================================================
  Files                         9        9           
  Lines                       541      541           
=====================================================
  Hits                        429      429           
  Misses                      112      112           
Flag Coverage Δ
unit 79.29% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

* name: 'my-plugin/navigate',
* label: 'Navigate to URL',
* description: 'Navigates to a URL within WordPress admin',
* location: 'client',
Copy link
Contributor Author

@emdashcodes emdashcodes Sep 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe location should be a part of meta? I made it a top level though like the Feature API had. This is also only used in the client package. The server will only know about server side abilities.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a broader question on how to best group abilities or describe where they can be executed and who can do it. Similarily to whether the permission callback is needed as noted in #69 (comment).

In WP Feature API, was there some assumption that client features are sent to the server? I don't fully understand why we would have to explicitly annotate that as this could be done through the registration process. In fact, we might end up in the situation where we have three types of abilities:

  • server-side only with execute_callback in PHP
  • client-side only with executeCallback in JavaScript
  • hybrid with execute_callback in PHP, and an alternative implementation on the client through executeCallback in JavaScript

The last one hybrid might be a good fit for entities from @wordpress/core-data. On the client, you could take advantage of optimistic updates, caching, etc, but on the server you can directly perform the same operations through WP functions.

}
if ( ! ability.callback || typeof ability.callback !== 'function' ) {
throw new Error(
'Client abilities must include a callback function'
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We add a number of strings here for errors. These are for developers, not end users, but I'm guessing we might still want to wrap this in gettext calls.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In PHP, even developer errors go through translation pipeline. In JavaScript things differ substantially. I'm not sure what the origin is, but looking at Gutenberg codebase, it should be fine to leave as is if it isn't user facing.

@swissspidy
Copy link
Member

Do we need permission callbacks on the client as well?

The input and output schemas are validated using Ajv and the ajv-formats plugin.

Probably the best choice.

Curious, how big does this make the bundle?

Ajv is already in use by Gutenberg so it is already part of the ecosystem.

What do mean with "already part of the ecosystem"? It's a mere dev dependency in Gutenberg for use in some tests. It's not in any of the published packages. That's a big difference and not really an argument for picking this dependency.

@emdashcodes emdashcodes force-pushed the add/client-only-abilities branch from ce5aa40 to 082dfdf Compare September 11, 2025 22:21
@emdashcodes
Copy link
Contributor Author

What do mean with "already part of the ecosystem"? It's a mere dev dependency in Gutenberg for use in some tests. It's not in any of the published packages. That's a big difference and not really an argument for picking this dependency.

It's also used in WordPress Playground and a few other utils too. I included it to say we have opted to use it in other places, which to me is worth considering over another package. I haven't looked at the bundle size (yet), but I personally think it is worth it instead of needing to hit a /validate endpoint or something instead for client side abilities.

@emdashcodes
Copy link
Contributor Author

Do we need permission callbacks on the client as well?

I was thinking about this as well and it's worth discussing. I think it would be nice to avoid needing to hit the REST API for running these, but maybe just providing a callback is enough? I'm open to ideas here!

@gziolo
Copy link
Member

gziolo commented Sep 12, 2025

https://bundlephobia.com/package/[email protected]

Screenshot 2025-09-12 at 09 14 48

It's a very reasonable size for the functionality it provides. In the long run we could reuse the logic in Gutenberg to validate block attributes schema in the development mode.


Do we need permission callbacks on the client as well?

It looks like on the server, we will enforce permission callbacks to increase the security as explained by @johnbillion in:

It still might be valuable to have a way to define permission callbacks that checks whether a user can do something in the UI that's only for priviliged users so they have more streamlined experience. Otherwise we risk AI shows too often an message about failed attempe to do something it is not allowed to do. Similarily, @galatanovidiu in this comment #62 (comment) proposed a new property that does more high-level checks whether certain abilities should be filtered out if it's known upfront (without providing specific input) they aren't allowed to use them.

Comment on lines +224 to +229
// Route to appropriate execution method
if (ability.location === 'client') {
return executeClientAbility(ability, input);
}

return executeServerAbility(ability, input);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking further my thoughts from https://github.com/WordPress/abilities-api/pull/69/files#r2343288632, would it be enough to use ability.callback instead of the location? For server-only ability it should neven be defined, so it'll need to do the apiFetch call regardless.

Comment on lines +64 to +73
// Only allow unregistering client abilities
if (state[action.name].location !== 'client') {
// eslint-disable-next-line no-console
console.warn(
`Cannot unregister server-side ability: ${action.name}`
);
return state;
}
const newState = { ...state };
delete newState[action.name];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting case. What if the intention of the developer is to prevent exposure and execution of some server-side abilities and they opt to do it through JavaScript? I know they could do it also with PHP, but it's a choice.

I'm curious to hear the rationale behind this additional check. Is there something that could break? Would it re-register when someone asks for the same ability?

@@ -27,28 +27,28 @@ export function getAbilities() {
{ per_page: PER_PAGE, page: 1 }
);

if ( ! firstPage || firstPage.length === 0 ) {
dispatch( receiveAbilities( [] ) );
if (!firstPage || firstPage.length === 0) {
Copy link
Member

@gziolo gziolo Sep 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably more feedback for the parent branch (on this diff it is most likely due to Prettier formatting), but this can be simplified by passing page: -1 which will automatically fetch all pages through a special fetch all middleware:

https://github.com/WordPress/gutenberg/blob/3cf0226ee98012dc24b0dc5f2d59715d13d8e1e7/packages/api-fetch/src/middlewares/fetch-all-middleware.ts#L66-L72

* 'client' means it's executed locally in the browser.
* Defaults to 'server'.
*/
location?: 'server' | 'client';
Copy link
Member

@gziolo gziolo Sep 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The more I see the code, I'm incrasingly confident it's redundant as it can be inferred from the existance of the optional callback property.

Aside, technically speaking a client-side ability could still use callback to call purely server-side ability. I'm mostly pointing out this distinction is mostly about where the callback's execution starts.

* @param param Optional parameter name for error messages.
* @return True if valid, error message string if invalid.
*/
export function validateValueFromSchema(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is nice! We might even find it a better home in a standalone WordPress package. I see extensive test coverage in #70 👍🏻

* ```
*/
export function registerAbility(ability: ClientAbility): void {
if (!ability.name) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should consider additional check to ensure we don't override accidentally an existing ability. This would mirror how it works on the server, and how WordPress usually handles registries.

Copy link
Member

@gziolo gziolo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is shaping up nicely. I left my feedback for consideration and to better understand some decisions made.

@gziolo gziolo changed the title Client-only ability registration Add client-only ability registration Sep 15, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Type] Enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants