1. What it is
The WordPress Abilities API is a core feature introduced in WordPress 6.9 that gives the platform a first-class way to register, describe and execute discrete units of functionality — called Abilities. Each Ability is a named action with a clearly defined input schema, an output schema, a permission check, and a PHP callback. The API exposes a central registry so plugins, themes, automation tools, and external systems (especially AI agents) can discover what a given WordPress site is capable of, and invoke those capabilities through a standard contract.
If you have spent years gluing the WordPress REST API to external
systems, the immediate value of the Abilities API will be obvious.
REST is route-oriented: every consumer has to learn which URL
handles which intent, how to construct the payload, what the
response shape is. Abilities are intent-oriented: every consumer
asks the registry for the verb they need (get-site-info,
send-email, update-option) and gets back
a self-describing object that tells them exactly what input it
wants, what output it produces, and whether the current user is
allowed to run it.
wp_register_ability() should be guarded
by class_exists('WP_Ability').
2. Why it matters
Six things change once a site exposes Abilities. They look small on paper. They compound in production.
Standardised
One registration shape across every plugin, theme and host. Stops the bespoke per-plugin REST proliferation.
Discoverable
AI agents can ask the site what it can do — by name, by category, by schema — without any hand-curated tool list.
Validated
JSON Schema enforces input and output shape before the callback runs. No more silent type coercion bugs.
Permissioned
Per-ability permission callbacks make capability checks colocated with the operation they gate.
Extensible
Hooks + filters let observers log, audit, modify args, even swap permission callbacks at runtime.
AI-friendly
Machine-readable label + description + schema means LLMs can plan tool calls without bespoke prompt scaffolding.
The single most important downstream consequence: the
Abilities API is the canonical substrate AI agents will use to
drive WordPress. If you have read the MCP Server for WordPress
series on this site, you already know the rough shape — an MCP
server exposes tools, each tool maps to a WordPress
operation, each operation needs a typed schema and a permission
check. The Abilities API gives you all of that natively, in core,
so your MCP server becomes a thin shim over wp_get_abilities()
instead of bespoke registration code per plugin.
3. Getting started
The smallest useful Abilities API example: register one ability, retrieve it, execute it. The structure below is intentionally boring — it is the shape every Ability in the wild will share.
<?php
if ( ! class_exists( 'WP_Ability' ) ) {
add_action( 'admin_notices', static function () {
wp_admin_notice(
esc_html__( 'Requires Abilities API (WordPress 6.9+).', 'my-plugin' ),
array( 'type' => 'error' )
);
} );
return;
}
add_action( 'wp_abilities_api_categories_init', 'myplugin_register_categories' );
function myplugin_register_categories() {
wp_register_ability_category(
'site-information',
array(
'label' => __( 'Site Information', 'my-plugin' ),
'description' => __( 'Read-only data about this WordPress site.', 'my-plugin' ),
)
);
}
add_action( 'wp_abilities_api_init', 'myplugin_register_abilities' );
function myplugin_register_abilities() {
wp_register_ability(
'my-plugin/get-site-title',
array(
'label' => __( 'Get Site Title', 'my-plugin' ),
'description' => __( 'Returns the current WordPress site title.', 'my-plugin' ),
'category' => 'site-information',
'output_schema' => array(
'type' => 'string',
'description' => 'The site title.',
),
'execute_callback' => static fn() => get_bloginfo( 'name' ),
'permission_callback' => '__return_true',
'meta' => array(
'show_in_rest' => true,
'annotations' => array( 'readonly' => true, 'destructive' => false ),
),
)
);
}
add_action( 'admin_init', static function () {
$ability = wp_get_ability( 'my-plugin/get-site-title' );
if ( ! $ability ) {
return;
}
$title = $ability->execute();
if ( ! is_wp_error( $title ) ) {
error_log( "Site title is: {$title}" );
}
} ); - 2
Guard against old WP
class_exists() check stops fatal errors on WP < 6.9 — non-negotiable for distributable plugins.
- 11
Register the category first
Categories must exist before any ability that references them — wp_abilities_api_categories_init fires earlier than _init.
- 22
Register the ability
Namespaced name + label + description + schemas + callbacks. Annotations route the HTTP verb.
- 43
Use it like any PHP value
wp_get_ability() pulls it from the registry. execute() returns the result or a WP_Error.
That fragment is enough to register a working ability, list it in
the registry, execute it programmatically, and (because
show_in_rest is on) expose it at
/wp-json/wp-abilities/v1/my-plugin/get-site-title/run.
Here is what an agent sees when it discovers it:
Ability
my-plugin/get-site-title
Get Site Title
Returns the current WordPress site title.
Input
— no input —
Output
- string title
The site title.
4. Core concepts
Seven primitives. Internalise these and the rest of the API reads itself.
- Ability
-
A discrete unit of functionality. Each one is an instance of
WP_Ability. Identified by a namespaced name (namespace/ability-name). Owns its own input schema, output schema, callback, permission callback, and metadata. - Category
-
A bucket that groups related abilities. Every ability belongs
to exactly one category. Categories are
WP_Ability_Categoryinstances. Must be registered before the abilities that use them. - Registry
-
A central singleton.
WP_Abilities_Registryfor abilities,WP_Abilities_Category_Registryfor categories. You rarely touch these directly — the global helpers (wp_get_ability(),wp_get_abilities(),wp_has_ability()) wrap them. - Callback
-
The PHP function or method that runs when an ability is
executed. Receives the validated input as its single
argument. Returns the result or a
WP_Erroron failure. - Schema
-
JSON Schema describing the input (
input_schema) and output (output_schema) shapes. Used for validation and for human/AI readability. Output schema is required, input schema is optional. - Permission callback
-
A function that returns
true,false, orWP_Errorbased on whether the current user may execute this ability. Receives the validated input. Required — no defaults. - Namespace
- The part of the ability name before the slash. By convention, your plugin slug. Lowercase, alphanumeric and hyphens only. Lets multiple plugins ship abilities without colliding.
5. Categories
Categories are the cheapest organisational primitive in the API, and the one most teams under-use. Their job is to give the registry a usable browse axis — so a consumer (admin UI, an AI agent, a documentation generator) can ask "show me everything that retrieves data" or "everything that touches users" without having to read 200 individual ability descriptions.
Ability categories
8 categories
data-retrieval
Data retrieval
Pure reads. Cache-safe. No side effects.
content
Content
Create / update posts, pages, and CPTs.
site-management
Site management
Plugins, themes, settings, options.
users
Users
Read or modify user accounts and roles.
media
Media
Upload, transform, attach images & files.
communication
Communication
Send notifications, email, webhooks.
ai
AI
Generative or analytical AI tasks.
utility
Utility
Catch-all for anything that doesn't fit.
- wp_register_ability_category
- wp_unregister_ability_category
- wp_get_ability_category
- wp_get_ability_categories
add_action( 'wp_abilities_api_categories_init', function ( $registry ) {
wp_register_ability_category(
'data-retrieval',
array(
'label' => __( 'Data Retrieval', 'my-plugin' ),
'description' => __( 'Read-only operations that return data from the site.', 'my-plugin' ),
)
);
wp_register_ability_category(
'data-modification',
array(
'label' => __( 'Data Modification', 'my-plugin' ),
'description' => __( 'Operations that write to or change site data.', 'my-plugin' ),
)
);
wp_register_ability_category(
'communication',
array(
'label' => __( 'Communication', 'my-plugin' ),
'description' => __( 'Operations that send messages, notifications, or email.', 'my-plugin' ),
)
);
} ); data_retrieval is invalid). No leading
or trailing dashes. No double dashes. No dots, slashes, or
spaces. Get this wrong and the registration silently fails —
wp_register_ability_category() returns null
and the ability that depends on the category will never register
either.
6. Registering abilities
wp_register_ability( string $name, array $args ) is
the central function in this entire API. The signature is small.
The arguments array is where the depth lives.
wp_register_ability(
'wporg/update-option',
array(
'label' => __( 'Update WordPress Option', 'wporg' ),
'description' => __( 'Updates the value of a WordPress option. Requires manage_options.', 'wporg' ),
'category' => 'data-modification',
'output_schema' => array(
'type' => 'object',
'properties' => array(
'success' => array( 'type' => 'boolean' ),
'previous_value' => array(),
),
),
'execute_callback' => 'wporg_update_option_callback',
'permission_callback' => static fn() => current_user_can( 'manage_options' ),
'input_schema' => array(
'type' => 'object',
'properties' => array(
'option_name' => array(
'type' => 'string',
'minLength' => 1,
'description' => 'The option key.',
),
'option_value' => array(
'description' => 'The new value to store.',
),
),
'required' => array( 'option_name', 'option_value' ),
'additionalProperties' => false,
),
'meta' => array(
'show_in_rest' => true,
'annotations' => array(
'destructive' => false,
'idempotent' => true,
'readonly' => false,
),
),
'ability_class' => 'WP_Ability',
)
); - 2
Namespaced name
namespace/ability — your plugin slug followed by a kebab-case verb. Lowercase, alphanumeric, hyphens.
- 4
Human label
Short, title-cased. Shown in admin UIs and tool pickers.
- 5
AI-facing description
The single most important field — this is what the LLM reads to decide whether to call your tool.
- 7
Output schema (required)
JSON Schema. The result is validated against this before being returned to the caller.
- 15
Execute + permission
execute_callback runs the work. permission_callback gates it. Both required.
- 18
Input schema (optional)
Validated BEFORE permission_callback. Bad input never reaches your gate.
- 33
Annotations
readonly / destructive / idempotent. These drive the HTTP verb on the REST endpoint.
- 41
Custom class (advanced)
Swap WP_Ability for your own subclass to wrap execute() with caching, timing, retries.
The arguments fall into three groups. Required: label, description, category,
output_schema, execute_callback,
permission_callback. Documenting
(strictly optional but you want them):
input_schema when the ability takes input,
meta.annotations for hints to consumers,
meta.show_in_rest to expose over HTTP.
Advanced: ability_class when you
need a custom WP_Ability subclass (covered in
section 10).
description field is the only context an LLM has
to decide whether to use this ability when planning a tool call.
Write it for that audience. State the intent, the side effects,
and the required capability. "Updates a WordPress option.
Requires manage_options." beats "Updates an option"
every time.
7. Annotations
Annotations live under meta.annotations and are
behavioural hints the registry surfaces to anyone listing the
ability. They are also what the REST endpoint uses to decide
which HTTP verb the ability accepts.
readonly
Pure read. Safe to cache, safe to retry, will be served over GET.
idempotent
Calling twice with the same args yields the same end state.
destructive
May delete or otherwise irreversibly mutate. Served over DELETE.
instructions
Free-text guidance the consumer can render to the user before invoking.
Defaults are conservative: readonly: false,
destructive: true, idempotent: false,
instructions: ''. That conservatism is intentional
— if you ship an ability without setting these, the API treats
it as the most dangerous possible operation. Annotate every
ability accurately and the REST layer will route the right HTTP
verb automatically.
'meta' => array(
'show_in_rest' => true,
'annotations' => array(
'readonly' => true, // GET /run
'destructive' => false, // not DELETE
'idempotent' => true, // safe to retry
'instructions' => 'Pass the post ID. Returns the full post object including custom meta.',
),
), 8. Hooks & filters
The API exposes four actions and two filters. Two actions are registration hooks (when to register); two are execution hooks (observing the run lifecycle). The filters let you intercept registration arguments at the last possible moment.
Hook lifecycle
Top → bottom in execution order
- 1
wp_abilities_api_categories_initactionWhen: Once, before any abilities are registered.
Use: Register your categories here so abilities that reference them can find them.
do_action( "wp_abilities_api_categories_init", $registry ) - 2
wp_abilities_api_initactionWhen: Once, after categories have been registered.
Use: Register your abilities here. The Abilities API equivalent of rest_api_init.
do_action( "wp_abilities_api_init", $registry ) - 3
wp_register_ability_argsfilterWhen: Each time wp_register_ability() runs.
Use: Mutate the registration array before the ability is frozen — inject annotations, tighten permission_callback.
apply_filters( "wp_register_ability_args", $args, $name ) - 4
wp_register_ability_category_argsfilterWhen: Each time wp_register_ability_category() runs.
Use: Adjust category metadata at registration time.
apply_filters( "wp_register_ability_category_args", $args, $slug ) - 5
wp_before_execute_abilityactionWhen: After validation + permission check, before the callback runs.
Use: Audit logs, correlation IDs, rate-limit counters.
do_action( "wp_before_execute_ability", $name, $input ) - 6
wp_after_execute_abilityactionWhen: After the callback returns and output is validated.
Use: Telemetry, cache invalidation, structured audit trail.
do_action( "wp_after_execute_ability", $name, $input, $result )
The two observability hooks are particularly useful for audit logging. Together they let you record every ability execution with caller, input, output, timestamp, and duration — without modifying any individual ability. This is the simplest path to a compliant audit trail for AI-driven WordPress operations.
wp_register_ability_args is a powerful filter
because it sees the full $args array before the
ability is instantiated. You can use it to wrap another
plugin's permission callback with an additional check — for
example, requiring a custom capability in addition to whatever
the plugin asked for. Always preserve the existing check; only
tighten, never loosen.
9. Using abilities in PHP
Once registered, abilities are first-class PHP citizens. Three global helpers cover almost every use case.
// Check whether an ability exists.
if ( wp_has_ability( 'wporg/get-site-info' ) ) { /* ... */ }
// Fetch one ability.
$ability = wp_get_ability( 'wporg/get-site-info' );
// Fetch all registered abilities (associative, keyed by name).
$all = wp_get_abilities();
foreach ( $all as $name => $ability ) {
printf( "%s — %s\n", $name, $ability->get_label() );
}
// Execute.
$result = $ability->execute( /* optional input */ );
// Inspect — useful when building admin UIs or docs generators.
$ability->get_name();
$ability->get_label();
$ability->get_description();
$ability->get_input_schema();
$ability->get_output_schema();
$ability->get_meta();
// Check permission without running the ability.
$can = $ability->check_permissions( $input );
if ( true === $can ) {
$result = $ability->execute( $input );
} elseif ( is_wp_error( $can ) ) {
error_log( $can->get_error_message() ); // internal error
} else {
// false — user simply not permitted
} execute() can return either a value or a
WP_Error. check_permissions() can
return true, false, or a
WP_Error. A truthy check
(if ($result)) treats WP_Error as a
valid result. Always check is_wp_error() first.
10. Custom ability classes
Pass ability_class in the registration args to use
a custom class that extends WP_Ability. This is the
extension point when you need behaviour the standard ability
doesn't offer — logging, retries, locking, instrumentation,
caching, anything that wraps the callback.
/**
* Adds structured timing + error logging around every execution.
*/
class Timed_Ability extends WP_Ability {
protected function do_execute( $input = null ) {
$start = microtime( true );
$result = parent::do_execute( $input );
$ms = (int) ( ( microtime( true ) - $start ) * 1000 );
error_log( sprintf(
'ability=%s ms=%d ok=%s',
$this->get_name(),
$ms,
is_wp_error( $result ) ? 'false' : 'true'
) );
return $result;
}
}
add_action( 'wp_abilities_api_init', function () {
wp_register_ability( 'wporg/heavy-stats', array(
'label' => __( 'Heavy Stats', 'wporg' ),
'description' => __( 'Computes site stats over the last 30 days.', 'wporg' ),
'category' => 'data-retrieval',
'output_schema' => array( 'type' => 'object' ),
'execute_callback' => 'wporg_compute_heavy_stats',
'permission_callback'=> static fn() => current_user_can( 'manage_options' ),
'ability_class' => Timed_Ability::class, // ← swap the class
) );
} );
Override the protected methods you need —
do_execute(), validate_input(),
validate_output() — and call the parent
implementation to keep the standard behaviour. The class must
extend WP_Ability; if it doesn't, registration
fails with a _doing_it_wrong() notice.
11. REST API endpoints
When you set meta.show_in_rest => true on an
ability, the API automatically exposes it under
/wp-json/wp-abilities/v1/. The endpoint surface is
five routes — list abilities, list categories, retrieve one of
each, execute. The execute route picks its HTTP verb from your
annotations.
Annotations
REST endpoint will accept
Default — neither readonly nor destructive. POST it is.
Rules
- · destructive: true →
DELETE - · readonly: true →
GET - · otherwise →
POST
# List all REST-exposed abilities (paginated).
GET /wp-json/wp-abilities/v1/abilities?per_page=50&category=data-retrieval
# List categories.
GET /wp-json/wp-abilities/v1/categories
GET /wp-json/wp-abilities/v1/categories/{slug}
# Retrieve a single ability's metadata.
GET /wp-json/wp-abilities/v1/{namespace}/{ability}
# Execute. HTTP verb depends on annotations:
GET /wp-json/wp-abilities/v1/{namespace}/{ability}/run # readonly: true
POST /wp-json/wp-abilities/v1/{namespace}/{ability}/run # readonly: false
DELETE /wp-json/wp-abilities/v1/{namespace}/{ability}/run # destructive: true
Inputs are passed as input (URL-encoded JSON in the
query string for GET/DELETE, JSON body for POST). The response
body is the raw value the ability returned — no envelope, no
wrapper. Errors come back as standard WordPress REST error
objects.
curl -X POST \
-u "USER:APP_PASSWORD" \
-H "Content-Type: application/json" \
-d '{"input":{"option_name":"blogname","option_value":"My Site"}}' \
https://example.com/wp-json/wp-abilities/v1/wporg/update-option/run {
"success": true,
"previous_value": "Old Site Name"
} {"input": {...}}. The endpoint
expects the wrapper. POSTing {"option_name":"foo"}
straight will fail validation as if you passed no input at all.
12. Authentication & errors
The Abilities REST API accepts every authentication method the core WP REST API does — cookie auth for same-origin browser requests, Application Passwords for external access, and any custom REST authentication plugin you have layered on. For AI-agent integrations, application passwords tied to a dedicated service user is the path of least surprise.
Every authenticated request follows the same six-stage lifecycle.
The red branches show where a WP_Error can exit early
— input validation runs before the permission check, so
malformed input never even reaches your permission_callback.
-
Request
JSON-RPC or REST
-
Validate input
JSON Schema
ability_invalid_input -
Check permission
permission_callback
ability_invalid_permissions -
Execute
execute_callback
WP_Error returned -
Validate output
output_schema
ability_invalid_output -
Respond
Typed result
Red branches show the WP_Error exit at each gate. Input validation runs before the permission check — invalid input never reaches your permission_callback.
The error surface is small and worth memorising — most failures fall into one of these seven codes. Filter by code or description:
Error codes
| Status | Code | Why | Fix |
|---|---|---|---|
| 400 | ability_missing_input_schema | Ability requires input but none was provided. | Pass an `input` object that matches the registered input_schema. |
| 400 | ability_invalid_input | Input did not validate against the ability's input_schema. | Check required fields, types, and additionalProperties on the schema. |
| 403 | ability_invalid_permissions | Current user lacks permission to execute the ability. | Verify the user's role; ensure permission_callback returns true for them. |
| 500 | ability_invalid_output | The callback returned a value that did not match output_schema. | Either fix the callback's return shape or relax the schema. |
| 500 | ability_invalid_execute_callback | The registered execute callback is not callable. | Plugin may have been deactivated, function renamed, or there is a typo. |
| 404 | rest_ability_not_found | Requested ability is not registered, or show_in_rest is false. | Set meta.show_in_rest => true and confirm the ability is registered. |
| 404 | rest_ability_category_not_found | Requested category does not exist. | Make sure the category is registered before any ability that references it. |
13. Best practices
Six principles, grouped into four categories. Check each box as you read — your browser will remember the state, so come back to this checklist before you ship and finish what's missing.
Production checklist
0 / 10
Design
Security
Errors
Observability
14. Abilities + MCP
The most consequential downstream consumer of the Abilities API
is the Model Context Protocol — the open
standard for letting AI agents discover and call typed tools.
Before the Abilities API existed, every MCP server for
WordPress was a bespoke per-plugin registration exercise:
hand-author one Zod schema, one fetch wrapper, one error map
per WP REST endpoint you wanted exposed. The Abilities API
collapses all of that to wp_get_abilities().
WordPress Ability
MCP Tool
Every field on the left has a one-to-one home on the right. That mapping is why the MCP server below is so short — there is no translation layer, just a forward.
// An MCP server that auto-exposes every registered Ability as an MCP tool.
const res = await fetch(`${WP_URL}/wp-json/wp-abilities/v1/abilities`, {
headers: { Authorization: `Basic ${WP_AUTH}` },
})
const abilities = await res.json() as Ability[]
for (const ability of abilities) {
server.tool(
ability.name,
ability.description,
inputSchemaToZod(ability.input_schema),
async (input) => {
const verb =
ability.meta?.annotations?.destructive ? 'DELETE' :
ability.meta?.annotations?.readonly ? 'GET' : 'POST'
const url = `${WP_URL}/wp-json/wp-abilities/v1/${ability.name}/run`
const res = await fetch(url, {
method: verb,
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${WP_AUTH}`,
},
body: verb === 'POST' ? JSON.stringify({ input }) : undefined,
})
const result = await res.json()
return { content: [{ type: 'text', text: JSON.stringify(result) }] }
}
)
} Roughly thirty lines of TypeScript and an MCP server now exposes every Abilities-API-registered tool on the site, auto-routed by HTTP verb based on annotations, with auth handled centrally. When a plugin author registers a new ability, the MCP server picks it up at the next reconnect — no code changes needed on the AI integration side.
15. Common gotchas
Ten things that will trip you the first time. Bookmark them.
add_action( 'wp_abilities_api_init', function () {
wp_register_ability_category( 'data', array(/* ... */) );
} ); add_action( 'wp_abilities_api_categories_init', function () {
wp_register_ability_category( 'data', array(/* ... */) );
} ); wp_register_ability_category( 'data_retrieval', array(/* ... */) ); wp_register_ability_category( 'data-retrieval', array(/* ... */) ); 'meta' => array(
// show_in_rest not set
), 'meta' => array(
'show_in_rest' => true,
), curl -X POST .../run \
-d '{"option_name":"blogname","option_value":"X"}' curl -X POST .../run \
-d '{"input":{"option_name":"blogname","option_value":"X"}}' 'annotations' => array(
'destructive' => true, // it actually only reads
), 'annotations' => array(
'readonly' => true,
'destructive' => false,
), $result = $ability->execute( $input );
if ( $result ) {
// WP_Error is truthy! This branch runs on failure too.
} $result = $ability->execute( $input );
if ( is_wp_error( $result ) ) {
error_log( $result->get_error_message() );
return;
}
// safe to use $result here // my-plugin.php loaded on WP < 6.9
wp_register_ability( 'my-plugin/foo', array(/* ... */) ); if ( ! class_exists( 'WP_Ability' ) ) {
return;
}
wp_register_ability( 'my-plugin/foo', array(/* ... */) ); 'permission_callback' => '__return_true',
// on an ability that deletes posts 'permission_callback' => static fn() =>
current_user_can( 'delete_posts' ),