Wp Abilities API
WordPressOfficialPublished by WordPress
- Skill
- Tool integration
- Software development
- GPL-2.0-or-later
- No issues found
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
Download the skill as a ZIP:
https://mp-staging.autogineer.com/agent-skills/wordpress/wp-abilities-api/downloadUnzip 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-triagefirst 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.isWpCoreCheckoutandversions.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_initwp_abilities_api_categories_initwp-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: truewhen the ability is informational,set
show_in_rest: truefor 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_restis enabled and you’re querying the right endpoint.
6) Consume from JS (if needed)
Prefer
@wordpress/abilitiesAPIs for client-side access and checks.Ensure build tooling includes the dependency and the project’s build pipeline bundles it.
Verification
wp-project-triageindicatessignals.usesAbilitiesApi: trueafter your change (if applicable).REST check (in a WP environment): endpoints under
wp-abilities/v1return 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_schemadefaults aren't being applied, pagination key drift between the ability and the backing, orempty()-based ID validation — seereferences/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.mdreferences/php-registration.md
Files
- ATTRIBUTION.mdAdded by AutoGineer896 B
- LICENSELicence file added by AutoGineer750 B
- SKILL.md5.2 KiB
- references/delegate-helper-pattern.md12.2 KiB
- references/domain-vs-projection.md8.9 KiB
- references/error-code-vocabulary.md7.0 KiB
- references/grouping-heuristic.md8.5 KiB
- references/input-schema-gotchas.md12.9 KiB
- references/php-registration.md6.4 KiB
- references/plugin-family-patterns.md11.7 KiB
- references/rest-api.md378 B
- references/shared-core-service.md14.8 KiB
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 againstnull, which works by accident but failsissetchecks 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' => [] ]) becomenullrather than[]and anyforeach ( $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_pageinput.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 casesAn 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:
! isset( ... )— the key is present on the array.! is_string( ... )— type is right. (For integer IDs, useis_int+ non-negative check instead.) Without this, a caller that passes123(integer) where the schema documents a string would fall through to a later step that doesrawurlencode( $input['order_id'] )and produces a cryptic coercion error rather than a clean missing-field response.'' === $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 runsWP_Ability::normalize_input()thenvalidate_input()(thencheck_permissions()) before the callback is invoked.
The two paths differ in three ways agents trip over:
Validation against
input_schemaruns only on the indirect path. If the ability declares an object-typed schema with properties and the indirect caller passesnull(or omits the argument entirely, as$ability->execute()does),validate_input()rejects with anability_invalid_inputerror before the callback runs. The PHP-level$input = nulldefault in the callback signature is dead code on this path.Schema's top-level
defaultIS applied — but only on the indirect path.normalize_input()substitutes the schema's top-leveldefaultwhen the caller passesnull. Direct callers don't run through this method; they get whatever PHP default the callback signature declares. Declaringdefault => (object) array()at the schema root makes$ability->execute()work without arguments — but the same callback called directly still receives PHPnull.The callback's first argument arrives only when
input_schemais non-empty. WordPress only forwards$inputto 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 anArgumentCountError.
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
nullis 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