Generated banner for Wp Abilities API
Generated logo for Wp Abilities API

Wp Abilities API

WordPressOfficialPublished by WordPress

  • Skill
  • Tool integration
  • Software development
  • GPL-2.0-or-later
  • No issues found

From the author

Use when working with the WordPress Abilities API (wp_register_ability, wp_register_ability_category, /wp-json/wp-abilities/v1/*, @wordpress/abilities) including defining abilities, categories, meta, REST exposure, and permissions checks for clients.
— the description by WordPress

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides a structured approach to working with the WordPress Abilities API. It covers the registration of abilities and categories in PHP, exposing them via the REST API, and consuming them in JavaScript using @wordpress/abilities. The documentation emphasizes a layered architecture—distinguishing between domain capabilities and their projections—to ensure stable, transport-neutral plugin contracts that remain maintainable as consumer requirements evolve.

What it does

  • Registers abilities and categories using the WordPress Abilities API.
  • Exposes domain capabilities through the REST API for client consumption.
  • Provides patterns for delegating ability execution to existing REST controllers.
  • Offers guidance on grouping heuristics to avoid atomic-ability bloat.
  • Defines standard error vocabulary for consistent agent-based error handling.
  • Facilitates client-side ability consumption via @wordpress/abilities.

When to use it

  • When adding new capabilities that should be discoverable by agents or external clients.
  • When refactoring existing REST endpoints into a more stable, action-oriented ability contract.
  • When building features that require consistent permission checks across different transport layers.
  • When debugging issues where abilities are not appearing in REST responses or client-side registries.

Advantages

  • Promotes a stable, transport-neutral domain layer that survives UI or consumer changes.
  • Reduces boilerplate by providing patterns for delegating to existing controller logic.
  • Ensures consistent error handling and input validation across the plugin ecosystem.
  • Improves discoverability of plugin actions for AI agents and automated workflows.

Usage examples

“Register a new ability for listing user orders in my plugin.”
The agent will guide you through defining the ability ID, category, and meta, ensuring it is registered via the correct hooks and exposed in the REST API.
“My ability is not showing up in the REST API; how do I debug this?”
The agent will help you verify the registration hook, check for the show_in_rest meta flag, and confirm the namespace and route configuration.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/wordpress/wp-abilities-api/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip wp-abilities-api.zip -d .agents/skills

Documentation by WordPress

WP Abilities API

When to use

Use this skill when the task involves:

  • registering abilities or ability categories in PHP,

  • exposing abilities to clients via REST (wp-abilities/v1),

  • consuming abilities in JS (notably @wordpress/abilities),

  • diagnosing “ability doesn’t show up” / “client can’t see ability” / “REST returns empty”.

Inputs required

  • Repo root (run wp-project-triage first if you haven’t).

  • Target WordPress version(s) and whether this is WP core or a plugin/theme.

  • Where the change should live (plugin vs theme vs mu-plugin).

Procedure

Before deciding what to register, read references/domain-vs-projection.md — abilities live at the domain capability layer; MCP / Command Palette / REST exposure is a projection. Registration shape and exposure shape are different decisions, and conflating them forces re-registration every time a consumer's constraints change.

1) Confirm availability and version constraints
  • If this is WP core work, check signals.isWpCoreCheckout and versions.wordpress.core.

  • If the project targets WP < 6.9, you may need the Abilities API plugin/package rather than relying on core.

2) Find existing Abilities usage

Search for these in the repo:

  • wp_register_ability(

  • wp_register_ability_category(

  • wp_abilities_api_init

  • wp_abilities_api_categories_init

  • wp-abilities/v1

  • @wordpress/abilities

If none exist, decide whether you’re introducing Abilities API fresh (new registrations + client consumption) or only consuming.

3) Register categories (optional)

If you need a logical grouping, register an ability category early (see references/php-registration.md).

4) Register abilities (PHP)

For grouping decisions (how many abilities to register, and where to put filters vs. new ability names), read references/grouping-heuristic.md first — it keeps you from shipping one atomic ability per REST operation.

To avoid drift between the ability and the existing UI / REST code path, see references/shared-core-service.md — abilities, REST handlers, CLI commands, and UI controllers should be thin adapters over a shared service. The reference also covers the metric trap (REST handlers that emit usage telemetry) and the AGENTS.md rule for keeping registrations in sync when underlying code paths change.

For shared helper patterns when multiple execute callbacks delegate to existing REST controllers, see references/plugin-family-patterns.md (identify the shared-API-client vs zero-arg-controllers shape) and references/delegate-helper-pattern.md (one helper shape that works, and when not to use it).

For standardized WP_Error codes that let agents reason about retry vs. escalation, see references/error-code-vocabulary.md.

Implement the ability in PHP registration with:

  • stable id (namespaced),

  • label/description,

  • category,

  • meta:

    • add readonly: true when the ability is informational,

    • set show_in_rest: true for abilities you want visible to clients.

Use the documented init hooks for Abilities API registration so they load at the right time (see references/php-registration.md).

5) Confirm REST exposure
  • Verify the REST endpoints exist and return expected results (see references/rest-api.md).

  • If the client still can’t see the ability, confirm meta.show_in_rest is enabled and you’re querying the right endpoint.

6) Consume from JS (if needed)
  • Prefer @wordpress/abilities APIs for client-side access and checks.

  • Ensure build tooling includes the dependency and the project’s build pipeline bundles it.

Verification

  • wp-project-triage indicates signals.usesAbilitiesApi: true after your change (if applicable).

  • REST check (in a WP environment): endpoints under wp-abilities/v1 return your ability and category when expected.

  • If the repo has tests, add/update coverage near:

    • PHP: ability registration and meta exposure

    • JS: ability consumption and UI gating

Failure modes / debugging

  • Ability never appears:

    • registration code not running (wrong hook / file not loaded),

    • missing meta.show_in_rest,

    • incorrect category/ID mismatch.

  • REST shows ability but JS doesn’t:

    • wrong REST base/namespace,

    • JS dependency not bundled,

    • caching (object/page caches) masking changes.

  • Execute callback returns unexpected errors or silently ignores input:

    • input_schema defaults aren't being applied, pagination key drift between the ability and the backing, or empty()-based ID validation — see references/input-schema-gotchas.md.

Escalation

  • If you’re uncertain about version support, confirm target WP core versions and whether Abilities API is expected from core or as a plugin.

  • For canonical details, consult:

    • references/rest-api.md

    • references/php-registration.md

Files

references/input-schema-gotchas.md

Input schema gotchas

Three surprises the Abilities API's input_schema will ship with if you rely on schema declarations alone. All three have been caught in real plugin work after the schema looked correct and tests passed; each has a defensive pattern that makes the execute callback robust regardless.

1. Schema default values are NOT injected into execute callback input

Problem

wp_register_ability() lets you declare per-property defaults in input_schema:

'input_schema' => [
    'type'       => 'object',
    'properties' => [
        'submit' => [
            'type'        => 'boolean',
            'default'     => false,
            'description' => __( 'Whether to submit evidence for review.', '<text-domain>' ),
        ],
    ],
],

The Abilities API's validate path enforces type and required, but it does NOT populate missing properties with their declared default. If an agent invokes the ability with { dispute_id: "dp_..." } and omits submit, the execute callback receives $input without a submit key — it does not get $input['submit'] = false.

Symptoms
  • Boolean defaults silently become "undefined" in the callback and any if ( $input['submit'] ) check compares against null, which works by accident but fails isset checks and strict-type branches.

  • Integer pagination defaults like 'page' => [ 'default' => 1 ] never reach the backing controller, so pagination falls back to the controller's internal default rather than the one declared in the schema.

  • Object defaults ('metadata' => [ 'default' => [] ]) become null rather than [] and any foreach ( $input['metadata'] as ... ) hits a type error.

Fix — normalize defaults explicitly in the execute callback
public static function execute_submit_evidence( $input = null ) {
    if ( ! is_array( $input ) ) {
        $input = [];
    }

    // Apply schema defaults the Abilities API does not inject.
    if ( ! array_key_exists( 'submit', $input ) || null === $input['submit'] ) {
        $input['submit'] = false;
    }
    $input['submit'] = (bool) $input['submit'];

    if ( ! array_key_exists( 'metadata', $input ) || null === $input['metadata'] ) {
        $input['metadata'] = [];
    }

    // ... rest of callback
}

The array_key_exists + null-check pattern catches both "missing key" and "explicit null" (some serializers produce nulls for optional fields).

Keep the declared default in input_schema anyway — it documents the expected behavior for anyone reading the registration and is visible to agents in the schema introspection endpoints. Just don't rely on it for runtime population.

2. Pagination parameter-name drift

Problem

The input_schema convention across most WordPress REST endpoints is per_page (matching WP core REST list endpoints and WP_REST_Controller's get_collection_params()). Some plugin REST controllers, however, delegate to an internal request-builder class that reads a different key (e.g. pagesize or page_size), typically for historical reasons.

If the ability's input_schema exposes per_page and the backing controller reads pagesize, the agent's per_page: 50 silently never reaches pagination — the value is accepted, forwarded verbatim to the backing, and then ignored. Agents keep getting the default page size and suspect their filter is broken.

Symptoms
  • List abilities return the backing controller's default page size regardless of the agent's per_page input.

  • Integration tests that only check "a list came back" pass; only a test asserting count( $response['data'] ) catches it.

  • Harness runs show the raw response count matching the default (25, 10, etc.) for every call.

Detection heuristic

Before shipping a paginated ability, grep the backing controller's call chain:

# Check the backing controller and anything it delegates to.
grep -rn "pagesize\|page_size" <path/to/backing-controller.php> <path/to/request-helpers/>

If pagesize (or any non-per_page key) appears in the chain and the ability's schema exposes per_page, the execute callback MUST translate.

Fix — translate before delegating
/**
 * Translate pagination keys for abilities whose backing reads a non-standard key.
 *
 * Abilities expose `per_page` uniformly for agent-facing consistency; this
 * helper rewrites it to whatever key the backing actually reads.
 *
 * @param array|null $input Ability input (or null).
 * @return array|null       Input with `per_page` rewritten to `<backing_key>` when present.
 */
private static function translate_pagination_keys( $input ) {
    if ( ! is_array( $input ) ) {
        return $input;
    }
    if ( isset( $input['per_page'] ) ) {
        $input['<backing_key>'] = $input['per_page'];
        unset( $input['per_page'] );
    }
    return $input;
}

public static function execute_get_things( $input = null ) {
    $input = self::translate_pagination_keys( is_array( $input ) ? $input : null );

    return self::delegate_to_rest_controller(
        '<Backing_Controller_Class>',
        'get_things',
        '/my-plugin/v1/things',
        $input
    );
}

Place the translation BEFORE the delegate call so per_page never reaches the backing.

When NOT to apply this

Do not apply blindly. If the backing reads per_page directly (most modern WP REST controllers do), adding this translation would silently break pagination by renaming the key to something the backing doesn't understand.

Always grep the backing first. The translation is only correct for a specific backing chain; it's not a general safety net.

3. ID validation must not use empty()

Problem

PHP's empty() is permissive in ways that bite ID validation:

empty( '0' )   // true  — but "0" is a legal string ID (order ID, row ID, post ID in some code paths)
empty( 0 )     // true  — same for integer zero
empty( [] )    // true  — expected, but same keyword used for different cases

An execute callback that guards required IDs with empty( $input['order_id'] ) will false-reject a legitimate "0" and return a "missing" error for input that's actually present. The agent retries with the same input, gets the same error, and the call is stuck.

Symptoms
  • Abilities reject specific IDs ending in zero or consisting of zero.

  • Unit tests written with non-zero IDs pass; a regression lands the day an agent tries a real zero-ID.

  • WP_Error( '<plugin>_missing_<field>' ) fires for input the schema validator would have accepted.

Fix — three explicit checks
if ( ! isset( $input['order_id'] )
    || ! is_string( $input['order_id'] )
    || '' === $input['order_id']
) {
    return new \WP_Error(
        '<plugin>_missing_order_id',
        __( 'An order_id is required to fetch order detail.', '<text-domain>' )
    );
}

Three separate checks, each non-redundant:

  1. ! isset( ... ) — the key is present on the array.

  2. ! is_string( ... ) — type is right. (For integer IDs, use is_int + non-negative check instead.) Without this, a caller that passes 123 (integer) where the schema documents a string would fall through to a later step that does rawurlencode( $input['order_id'] ) and produces a cryptic coercion error rather than a clean missing-field response.

  3. '' === $input[ ... ] — non-empty in the strict sense. Rejects empty string only; accepts "0" as a legal value.

Use the standardized <plugin>_missing_<field> error code (see error-code-vocabulary.md) for this case.

Add a regression-guard unit test

The guard is load-bearing enough that it deserves an explicit test — otherwise someone will simplify it back to empty() in a future refactor:

public function test_execute_returns_wp_error_when_id_not_a_string() {
    $result = My_Plugin_Abilities::execute_get_thing( [ 'order_id' => 123 ] );
    $this->assertInstanceOf( \WP_Error::class, $result );
    $this->assertSame( '<plugin>_missing_order_id', $result->get_error_code() );
}

The integer 123 is a fine canary — it's non-empty, it issets, but it's not a string. A callback that only uses isset + empty would false-pass this input and fall through to the URL construction with an integer argument.

4. Direct vs indirect invocation differ in strictness

Problem

Two ways exist to invoke an ability's execute_callback:

  • Direct. A caller imports the registrar and calls the static method (Abilities_Registrar::execute_get_things( $input )). PHP signature defaults apply; no schema validation runs.

  • Indirect. A caller resolves the ability through wp_get_ability( '<id>' )->execute( $input ). WordPress runs WP_Ability::normalize_input() then validate_input() (then check_permissions()) before the callback is invoked.

The two paths differ in three ways agents trip over:

  1. Validation against input_schema runs only on the indirect path. If the ability declares an object-typed schema with properties and the indirect caller passes null (or omits the argument entirely, as $ability->execute() does), validate_input() rejects with an ability_invalid_input error before the callback runs. The PHP-level $input = null default in the callback signature is dead code on this path.

  2. Schema's top-level default IS applied — but only on the indirect path. normalize_input() substitutes the schema's top-level default when the caller passes null. Direct callers don't run through this method; they get whatever PHP default the callback signature declares. Declaring default => (object) array() at the schema root makes $ability->execute() work without arguments — but the same callback called directly still receives PHP null.

  3. The callback's first argument arrives only when input_schema is non-empty. WordPress only forwards $input to the callback when the schema is declared. Without an input schema, the callback is invoked with zero arguments — PHP-level signature defaults compensate for this if you wrote them; without them, the indirect path produces an ArgumentCountError.

Symptoms
  • An ability looks fine when unit-tested by directly invoking the static method, then fails the moment it's exercised through wp_get_ability(...)->execute() from MCP / Command Palette / agent harnesses.

  • "Works in the test, breaks in MCP" — typical pattern when a test calls Abilities_Registrar::execute_get_things( [] ) (passing an empty array) but the agent invocation goes through the indirect pipeline.

  • A schema with all-optional properties still rejects zero-arg indirect calls because null is not an object.

Fix — declare a top-level schema default for any zero-arg-allowed ability
'input_schema' => [
    'type'       => 'object',
    'default'    => (object) array(),  // applied by normalize_input() on null inputs
    'properties' => [
        'per_page' => [ 'type' => 'integer', 'default' => 10 ],
        // ...
    ],
],

(object) array() is preferred over [] because it json-encodes to {} rather than the array literal [], avoiding PHP's array/object ambiguity at the validator boundary.

Distinguish from Gotcha 1

Top-level schema default is honored by normalize_input() and reaches the callback. Property-level default (Gotcha 1) is NOT — those values are dropped by the validator, and the callback has to defensively reapply them. Different layers, different fates.

If you've declared the top-level default in the schema, the PHP-level signature default exists only for direct callers. Don't add a third layer of fallback inside the callback that re-checks if ( $input === null ) — three compensating defaults stacked on each other diffuses the meaning of "no input."

Putting gotchas 1-3 together

A hardened execute callback for a list-style ability with a required ID, schema defaults, and backing pagination drift:

public static function execute_get_thing_details( $input = null ) {
    if ( ! is_array( $input ) ) {
        $input = [];
    }

    // Gotcha 3: required-ID validation (not empty()).
    if ( ! isset( $input['thing_id'] )
        || ! is_string( $input['thing_id'] )
        || '' === $input['thing_id']
    ) {
        return new \WP_Error(
            '<plugin>_missing_thing_id',
            __( 'A thing_id is required.', '<text-domain>' )
        );
    }

    // Gotcha 1: apply defaults the Abilities API doesn't inject.
    if ( ! array_key_exists( 'include_history', $input ) || null === $input['include_history'] ) {
        $input['include_history'] = false;
    }
    $input['include_history'] = (bool) $input['include_history'];

    // Gotcha 2: translate pagination before delegating (only if backing reads a non-standard key).
    $input = self::translate_pagination_keys( $input );

    return self::delegate_to_rest_controller(
        '<Backing_Controller_Class>',
        'get_thing',
        '/my-plugin/v1/things/' . rawurlencode( $input['thing_id'] ),
        $input
    );
}

Each of the three lines the callback adds on top of the "just delegate" pattern has a paid-for bug behind it. Keep them.

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

The author says: Targets WordPress 7.0+ (PHP 7.4.0+). Filesystem-based agent with bash + node. Some workflows require WP-CLI.

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
WordPress
Publisher
WordPress (Official)
Source
WordPress/agent-skills · skills/wp-abilities-api · commit 7374e6f
Licence
GNU General Public License v2.0 or later (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by WordPress.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download wp-abilities-api.zip