All skills
wordpress avatar

/wp-abilities-api

@20324d2 official
by wordpresswordpress/agent-skills2.2k stars
327

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.

Use this Skill: https://skilld.dev/gh/wordpress/agent-skills/wp-abilities-api

This session only. Nothing lands on disk.

referencesinput-schema-gotchas.md

≈3.3k tokens on demand. Your agent reads this file only when SKILL.md points to it.

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.

Source: SKILL.md on GitHub

1 warning17d5 checks · Risk SAFE
  • Gen Agent Trust Hub17d

    The skill provides comprehensive instructions for implementing and using the WordPress Abilities API. It includes detailed architectural guidance, common development pitfalls, and standardized error handling. The security analysis identifies a low-level risk of indirect prompt injection common to tools that process external codebases.

  • Socket17d

    No alerts

  • Snyk17d

    Risk: LOW · No issues

  • Runlayer7mo

    3/3 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at 20324d2. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 days ago.

Activeupdated 3 months ago
Other metadata
compatibility
Targets WordPress 7.0+ (PHP 7.4.0+). Filesystem-based agent with bash + node. Some workflows require WP-CLI.
  • wordpress
  • abilities-api
  • php
  • rest-api
  • capabilities
  • permissions
  • javascript
  • wp-cli

README badge

README badge for wordpress/agent-skills/wp-abilities-api

Registers and exposes WordPress abilities (discrete capability units) via the WordPress Abilities API in PHP, then consumes them in JavaScript clients using @wordpress/abilities. Targets WordPress 6.9+ and covers ability registration, REST exposure, category grouping, and permission checks across domain and projection layers.

Generated from the current SKILL.md.

What WordPress versions does this skill support?
WordPress 6.9+ with PHP 7.2.24+. For earlier versions, you may need the separate Abilities API plugin or package instead of relying on core.
Does this skill help with REST exposure of abilities?
Yes. The skill covers exposing abilities to clients via the wp-abilities/v1 REST endpoints by setting `meta.show_in_rest: true` during registration and verifying endpoint responses.
Can I use this skill to consume abilities on the client side?
Yes. The skill covers consuming abilities in JavaScript using the @wordpress/abilities package and ensuring the build tooling includes and bundles that dependency.
What should I read before registering abilities?
Read references/domain-vs-projection.md to understand that abilities live at the domain capability layer, separate from their REST/MCP/Command Palette projection—registration shape and exposure shape are different decisions.
How do I debug an ability that doesn't appear in REST or to clients?
Check that registration code is running on the correct hook, verify `meta.show_in_rest` is enabled, confirm category/ID match, and rule out object/page cache masking changes.

Generated from the current SKILL.md. These answers refresh after source changes.