MW
Deep dive 22 min read

The WordPress Abilities API — complete guide

A standardised way to register, discover and execute discrete units of functionality across WordPress — for plugins, themes, automation tools, and AI agents.

WordPress 6.9 Abilities API AI REST API JSON Schema
Auto-cycle
1. Caller discovers what the site can do via the registry.

Click a node to step through

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.

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.

my-plugin.php — the four-move starter
php
<?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}" );
    }
} );
  1. 2

    Guard against old WP

    class_exists() check stops fatal errors on WP < 6.9 — non-negotiable for distributable plugins.

  2. 11

    Register the category first

    Categories must exist before any ability that references them — wp_abilities_api_categories_init fires earlier than _init.

  3. 22

    Register the ability

    Namespaced name + label + description + schemas + callbacks. Annotations route the HTTP verb.

  4. 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

GET site-information

Returns the current WordPress site title.

readonly idempotent

Input

— no input —

Output

  • string
    title

    The site title.

Permission-gated · Schema-validated

Execute

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_Category instances. Must be registered before the abilities that use them.
Registry
A central singleton. WP_Abilities_Registry for abilities, WP_Abilities_Category_Registry for 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_Error on 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, or WP_Error based 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.

get-postlist-usersget-site-info

content

Content

Create / update posts, pages, and CPTs.

create-postupdate-page

site-management

Site management

Plugins, themes, settings, options.

toggle-pluginupdate-option

users

Users

Read or modify user accounts and roles.

get-userupdate-role

media

Media

Upload, transform, attach images & files.

upload-mediaattach-image

communication

Communication

Send notifications, email, webhooks.

send-emailfire-webhook

ai

AI

Generative or analytical AI tasks.

summarize-posttranslate

utility

Utility

Catch-all for anything that doesn't fit.

pingecho
  • wp_register_ability_category
  • wp_unregister_ability_category
  • wp_get_ability_category
  • wp_get_ability_categories
categories.php php
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' ),
        )
    );
} );

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() — the full argument map
php
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',
    )
);
  1. 2

    Namespaced name

    namespace/ability — your plugin slug followed by a kebab-case verb. Lowercase, alphanumeric, hyphens.

  2. 4

    Human label

    Short, title-cased. Shown in admin UIs and tool pickers.

  3. 5

    AI-facing description

    The single most important field — this is what the LLM reads to decide whether to call your tool.

  4. 7

    Output schema (required)

    JSON Schema. The result is validated against this before being returned to the caller.

  5. 15

    Execute + permission

    execute_callback runs the work. permission_callback gates it. Both required.

  6. 18

    Input schema (optional)

    Validated BEFORE permission_callback. Bad input never reaches your gate.

  7. 33

    Annotations

    readonly / destructive / idempotent. These drive the HTTP verb on the REST endpoint.

  8. 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).

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.

annotations.php php
'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. 1
    wp_abilities_api_categories_init action

    When: 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. 2
    wp_abilities_api_init action

    When: 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. 3
    wp_register_ability_args filter

    When: 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. 4
    wp_register_ability_category_args filter

    When: Each time wp_register_ability_category() runs.

    Use: Adjust category metadata at registration time.

    apply_filters( "wp_register_ability_category_args", $args, $slug )
  5. 5
    wp_before_execute_ability action

    When: 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. 6
    wp_after_execute_ability action

    When: 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.

9. Using abilities in PHP

Once registered, abilities are first-class PHP citizens. Three global helpers cover almost every use case.

use-abilities.php php
// 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
}

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.

custom-ability-class.php php
/**
 * 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.

HTTP verb router — try the toggles

Annotations

REST endpoint will accept

POST /wp-json/wp-abilities/v1/wporg/update-option/run

Default — neither readonly nor destructive. POST it is.

Rules
  • · destructive: trueDELETE
  • · readonly: trueGET
  • · otherwise → POST
endpoints.txt bash
# 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.

Request
POST request bash
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
Response
response.json json
{
  "success": true,
  "previous_value": "Old Site Name"
}

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.

Ability execution — decision flow
  1. Request

    JSON-RPC or REST

  2. Validate input

    JSON Schema

    ability_invalid_input
  3. Check permission

    permission_callback

    ability_invalid_permissions
  4. Execute

    execute_callback

    WP_Error returned
  5. Validate output

    output_schema

    ability_invalid_output
  6. 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

Tick progress is saved locally — close the tab and your state persists.

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
name
name
description
description
input_schema
inputSchema
output_schema
outputSchema
permission_callback
server-side ACL
annotations.readonly
HTTP verb
execute_callback
tool handler
Roughly thirty lines of TS auto-maps every registered Ability → MCP tool. No bespoke registration per plugin.

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.

mcp-server.ts typescript
// 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.

Wrong init hook for categories
Wrong
add_action( 'wp_abilities_api_init', function () {
    wp_register_ability_category( 'data', array(/* ... */) );
} );
Right
add_action( 'wp_abilities_api_categories_init', function () {
    wp_register_ability_category( 'data', array(/* ... */) );
} );
Silent failure. Abilities that reference the category will refuse to register.
Underscore in category slug
Wrong
wp_register_ability_category( 'data_retrieval', array(/* ... */) );
Right
wp_register_ability_category( 'data-retrieval', array(/* ... */) );
Returns null. Slugs must be lowercase, hyphenated, alphanumeric.
Forgetting show_in_rest
Wrong
'meta' => array(
    // show_in_rest not set
),
Right
'meta' => array(
    'show_in_rest' => true,
),
REST returns rest_ability_not_found. The ability exists in PHP but is not HTTP-reachable.
POSTing input without wrapper
Wrong
curl -X POST .../run \
  -d '{"option_name":"blogname","option_value":"X"}'
Right
curl -X POST .../run \
  -d '{"input":{"option_name":"blogname","option_value":"X"}}'
Validation rejects as if no input was passed. The {"input": ...} wrapper is required.
Lying about destructive
Wrong
'annotations' => array(
    'destructive' => true, // it actually only reads
),
Right
'annotations' => array(
    'readonly'    => true,
    'destructive' => false,
),
Endpoint accepts DELETE only. AI planners avoid the ability thinking it is dangerous.
Truthy check on execute()
Wrong
$result = $ability->execute( $input );
if ( $result ) {
    // WP_Error is truthy! This branch runs on failure too.
}
Right
$result = $ability->execute( $input );
if ( is_wp_error( $result ) ) {
    error_log( $result->get_error_message() );
    return;
}
// safe to use $result here
PHP treats WP_Error as truthy. Errors silently flow into success paths.
No version guard
Wrong
// my-plugin.php loaded on WP < 6.9
wp_register_ability( 'my-plugin/foo', array(/* ... */) );
Right
if ( ! class_exists( 'WP_Ability' ) ) {
    return;
}
wp_register_ability( 'my-plugin/foo', array(/* ... */) );
Fatal error on activation. Always guard with class_exists() for distributable plugins.
Permission of __return_true on writes
Wrong
'permission_callback' => '__return_true',
// on an ability that deletes posts
Right
'permission_callback' => static fn() =>
    current_user_can( 'delete_posts' ),
Anyone authenticated can wipe data. Always check a specific capability for writes.

Abilities make AI-driven WordPress sane

One registration shape. Typed everywhere. AI-ready by default.

WP 6.9 just unlocked the next five years of integration.

  • Typed
  • Permissioned
  • AI-native