LW Site Manager: extending with custom abilities
SkillAI & modelsAdd custom abilities to the LW Site Manager hub via its extension contract — two action hooks (lw_site_manager_register_categories for category labels, lw_site_manager_register_abilities for the abilities themselves; the second receives the PermissionManager so external abilities reuse the typed cap-check methods). Pattern — extend AbstractAbilitiesRegistrar to inherit the meta builders (readOnlyMeta / writeMeta / destructiveMeta) and schema builders (paginationSchema/orderingSchema/idSchema/listOutputSchema/entityOutputSchema/successOutputSchema/bulkResultSchema/updateResultSchema). Don't bypass — meta annotations (readonly / destructive / idempotent) are how AI agents reason about ability safety; inconsistent helpers make the hub surface heterogeneous. The plugin emits NO filters; it's additively extensible only. Use when shipping a companion plugin that adds site-manager/* abilities. Triggers on lw_site_manager_register_abilities, lw_site_manager_register_categories, AbstractAbilitiesRegistrar.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the LW Site Manager: extending with custom abilities skill
What this skill tells your AI
The instructions your AI receives, as published by lonsdale201/wp-agent-skills in lw-plugins/lw-site-manager-extend-abilities/SKILL.md and read by ahel’s review.
For developers shipping a companion plugin that adds abilities to the LW Site Manager hub — without forking, without registering a parallel namespace. The plugin exposes a small but precise extension contract: two action hooks plus a reusable abstract registrar. Done right, your custom abilities appear seamlessly in the hub's catalog with consistent meta annotations and capability checks; done wrong, they look like an unrelated plugin that happens to share a prefix.
Misconception this skill corrects
"I'll just call
wp_register_ability('site-manager/my-thing', [...])directly onwp_abilities_api_init— same outcome."
It registers the ability, yes. But it bypasses the plugin's central infrastructure:
- No shared
PermissionManager— yourpermission_callbackis some inlinecurrent_user_can('edit_posts')rather than the typed$permissions->callback('can_edit_posts'). When WP shifts a capability mapping (rare but happens — comment editing migrated cap), every ability that calledcurrent_user_candirectly needs its own update; abilities routed throughPermissionManagerget the fix in one place. - No
AbstractAbilitiesRegistrarhelpers — meta annotations (readonly/destructive/idempotent) and output envelope shapes (success / list / entity / bulk-result / update-result) become whatever you remembered to build by hand. Surface looks heterogeneous to AI agents; some abilities tagreadonly: truefor read endpoints, yours doesn't, agent doesn't realize a "list" is safe to retry. - Wrong category — if you forget
categoryor set one the plugin's category map doesn't recognize, the ability registers but doesn't appear in the plugin's grouped admin UI / catalog views.
The contract is two action hooks + one abstract class to extend. Verified at:
lw_site_manager_register_categoriesaction — lw-site-manager.php:232. Fires inside the plugin's category-init hook AFTER its own categories register, so external categories don't collide with built-in ones.lw_site_manager_register_abilitiesaction — src/Abilities/Registrar.php:48. Fires AFTER the plugin's own four registrars run, with thePermissionManagerinstance as the only argument:do_action( 'lw_site_manager_register_abilities', $this->permissions );.
Other AI-prone misconceptions:
- "I'll add a filter to mutate one of the built-in abilities' inputs." Wrong — the plugin emits zero filters (verified by grepping the source). Built-in abilities are not interceptable from this plugin's contract; use the WP Abilities API's general lifecycle filters (
ability_*family) if available, or wrap the ability call site-side. - "I should add abilities under MY OWN namespace (
my-plugin/foo) instead ofsite-manager/." That's also valid, but it doesn't show up in the LW Site Manager catalog/UI and AI agents discovering through the hub's surface won't find them. The two-namespace question is "do I want the hub UX or not?" — picksite-manager/*to live in the hub. - "The PermissionManager is private; I'll roll my own." It's
publicand explicitly passed as the action argument because external use is the design intent. Read thecallback($methodName)helper at PermissionManager.php:277 — that's the canonical way.
When to use this skill
Trigger when ANY of the following is true:
- A diff calls
add_action( 'lw_site_manager_register_abilities', ... )orlw_site_manager_register_categories. - Adding
extends AbstractAbilitiesRegistrar(or an inheritance chain reaching it). - A user / PR plans to ship a companion plugin that adds
site-manager/*abilities. - Reviewing a PR that hand-rolls
wp_register_abilityfor thesite-manager/namespace without using the helpers.
Workflow
1. Bootstrap your companion plugin
add_action( 'lw_site_manager_register_categories', 'mycompanion_register_categories' );
add_action( 'lw_site_manager_register_abilities', 'mycompanion_register_abilities', 10, 1 );
function mycompanion_register_categories(): void {
wp_register_ability_category(
'mycompanion-newsletter',
[
'label' => __( 'Newsletter', 'mycompanion' ),
'description' => __( 'Newsletter subscriber and campaign abilities.', 'mycompanion' ),
]
);
}
function mycompanion_register_abilities( \LightweightPlugins\SiteManager\Abilities\PermissionManager $permissions ): void {
( new \MyCompanion\Abilities\NewsletterAbilitiesRegistrar( $permissions ) )->register();
}
The register_categories callback runs at category-init time. The register_abilities callback fires AFTER the four built-in registrars, with PermissionManager as the argument.
2. Extend AbstractAbilitiesRegistrar
Mirror the plugin's own registrar pattern at src/Abilities/Registrars/:
namespace MyCompanion\Abilities;
use LightweightPlugins\SiteManager\Abilities\PermissionManager;
use LightweightPlugins\SiteManager\Abilities\Registrars\AbstractAbilitiesRegistrar;
class NewsletterAbilitiesRegistrar extends AbstractAbilitiesRegistrar
{
public function register(): void
{
$this->register_list_subscribers();
$this->register_create_subscriber();
$this->register_send_campaign();
}
private function register_list_subscribers(): void
{
wp_register_ability(
'site-manager/newsletter-list-subscribers',
[
'label' => __( 'List Newsletter Subscribers', 'mycompanion' ),
'description' => __( 'List newsletter subscribers with filters and pagination.', 'mycompanion' ),
'category' => 'mycompanion-newsletter',
'input_schema' => [
'type' => 'object',
'default' => [],
'properties' => array_merge(
$this->listingSchema( defaultLimit: 50, defaultBy: 'created_at', defaultOrder: 'DESC' ),
$this->searchSchema(),
$this->statusSchema( allowed: [ 'all', 'active', 'unsubscribed' ], default: 'active' ),
),
],
'output_schema' => $this->listOutputSchema( 'subscribers', $this->subscriberSchema() ),
'execute_callback' => [ \MyCompanion\Services\NewsletterManager::class, 'list_subscribers' ],
'permission_callback' => $this->permissions->callback( 'can_manage_options' ),
'meta' => $this->readOnlyMeta( idempotent: true ),
]
);
}
private function register_create_subscriber(): void
{
wp_register_ability(
'site-manager/newsletter-create-subscriber',
[
'label' => __( 'Create Subscriber', 'mycompanion' ),
'description' => __( 'Add a new newsletter subscriber.', 'mycompanion' ),
'category' => 'mycompanion-newsletter',
'input_schema' => [
'type' => 'object',
'default' => [],
'properties' => [
'email' => [ 'type' => 'string', 'required' => true ],
'name' => [ 'type' => 'string' ],
],
],
'output_schema' => $this->entityOutputSchema( 'subscriber', $this->subscriberSchema() ),
'execute_callback' => [ \MyCompanion\Services\NewsletterManager::class, 'create_subscriber' ],
'permission_callback' => $this->permissions->callback( 'can_manage_options' ),
'meta' => $this->writeMeta( idempotent: false ),
]
);
}
private function register_send_campaign(): void
{
wp_register_ability(
'site-manager/newsletter-send-campaign',
[
'label' => __( 'Send Campaign', 'mycompanion' ),
'description' => __( 'Trigger a newsletter campaign send. Cannot be undone.', 'mycompanion' ),
'category' => 'mycompanion-newsletter',
'input_schema' => [
'type' => 'object',
'default' => [],
'properties' => [
'campaign_id' => [ 'type' => 'integer', 'required' => true, 'minimum' => 1 ],
],
],
'output_schema' => $this->successOutputSchema(),
'execute_callback' => [ \MyCompanion\Services\NewsletterManager::class, 'send_campaign' ],
'permission_callback' => $this->permissions->callback( 'can_manage_options' ),
'meta' => $this->destructiveMeta( idempotent: false ),
]
);
}
private function subscriberSchema(): array
{
return [
'type' => 'object',
'default' => [],
'properties' => [
'id' => [ 'type' => 'integer' ],
'email' => [ 'type' => 'string' ],
'name' => [ 'type' => 'string' ],
'status' => [ 'type' => 'string' ],
'created_at' => [ 'type' => 'string' ],
],
];
}
}
This earns:
- Consistent meta annotations (read-only list with
idempotent: true, destructive send withdestructive: true) so AI agents can correctly reason about retry safety and risk. - Consistent output envelope shapes (
listOutputSchemawithtotal/total_pages/has_more;entityOutputSchemawithsuccess/message/ entity body;successOutputSchemawithsuccess/message). - Consistent input schemas (pagination / ordering / status / search via the helper builders).
- Centralized permission routing —
$this->permissions->callback('can_manage_options')instead of inlinecurrent_user_can.
3. Inherited helpers — verified API
From src/Abilities/Registrars/AbstractAbilitiesRegistrar.php:
Meta builders (lines 38-77):
$this->buildMeta( readonly: false, destructive: false, idempotent: false ) // raw, full control
$this->readOnlyMeta( idempotent: true ) // for reads (default idempotent: true)
$this->writeMeta( idempotent: false ) // for non-destructive writes
$this->destructiveMeta( idempotent: true ) // for destructive ops (default idempotent: true — common for "delete X by id")
Each returns:
[
'show_in_rest' => true,
'annotations' => [
'readonly' => bool,
'destructive' => bool,
'idempotent' => bool,
],
]
Schema builders (lines 89-188):
| Helper | Returns | Use for |
|---|---|---|
paginationSchema(int $defaultLimit = 20) | limit + offset properties | Lists |
orderingSchema(string $defaultBy = 'date', string $defaultOrder = 'DESC', array $allowedBy = []) | orderby + order properties | Lists |
listingSchema(int $defaultLimit = 20, string $defaultBy = 'date', string $defaultOrder = 'DESC') | both above merged | Most lists |
statusSchema(array $allowed, string $default = 'any') | status enum property | Filtered lists |
searchSchema() | search string property | Searchable lists |
idSchema(string $description = 'Item ID', bool $required = true) | id integer >=1 | Get / update / delete |
slugSchema(string $description = 'Item slug') | slug string | Slug-keyed lookups |
Output envelope builders (lines 200-306):
| Helper | Output shape |
|---|---|
successOutputSchema(array $additionalProperties = []) | {success, message, ...$additionalProperties} |
listOutputSchema(string $key, array $itemSchema) | {$key: [items], total, total_pages, limit, offset, has_more} |
entityOutputSchema(string $key, array $entitySchema) | {success, message, $key: entity} |
updateResultSchema() | {success, message, old_version, new_version, php_errors} |
bulkResultSchema() | {success, action, processed, failed, total, success_ids, failed_ids, message} |
4. Use the central PermissionManager
// In your ability registration:
'permission_callback' => $this->permissions->callback( 'can_manage_options' ),
PermissionManager::callback($method) (src/Abilities/PermissionManager.php:277) returns [$this, $method] — a standard PHP callable for the WP Abilities API.
Available cap-check methods (verified):
| Method | Caps required |
|---|---|
can_manage_updates | update_plugins AND update_themes |
can_install_plugins | install_plugins |
can_install_themes | install_themes |
can_manage_plugins | activate_plugins |
can_manage_themes | switch_themes |
can_manage_backups | manage_options |
can_view_health | view_site_health_checks |
can_manage_database / _cache / _options | manage_options |
can_manage_users | list_users |
can_create_users / can_edit_users / can_delete_users | create_users / edit_users / delete_users |
can_edit_posts / can_publish_posts / can_delete_posts / can_edit_others_posts | corresponding caps |
can_edit_pages / can_publish_pages / can_delete_pages / can_edit_others_pages | corresponding caps |
can_moderate_comments / can_edit_comments | moderate_comments / edit_posts |
can_upload_files | upload_files |
can_manage_categories / can_manage_tags | manage_categories |
has_any_capability(array $caps) | OR of caps |
has_all_capabilities(array $caps) | AND of caps |
If your ability needs a cap not in this map (e.g. manage_woocommerce), you can either:
- Pass an inline closure:
'permission_callback' => static fn (): bool => current_user_can('manage_woocommerce'). Loses central updateability but works. - Add a method to your own subclass / wrapper that delegates:
static fn (): bool => current_user_can('manage_woocommerce'). Same result, slightly cleaner. - Use
has_any_capability(['manage_woocommerce', 'manage_options'])for OR semantics.
5. Choose the right meta annotation
The readonly / destructive / idempotent triple is how AI agents reason about safety:
| Annotation | Meaning | When true |
|---|---|---|
readonly | Doesn't mutate state | List / get / health-check / error-log |
destructive | Loses data; cannot be undone trivially | Delete / send-email / restore-backup |
idempotent | Repeat calls produce the same result | Reads, Set-X-to-Y, deletes (already-deleted is no-op) |
Some examples from the plugin's own usage:
site-manager/list-posts→readOnlyMeta(idempotent: true)— read, repeat-safe.site-manager/create-post→writeMeta(idempotent: false)— each call creates a new post; not idempotent.site-manager/delete-post→destructiveMeta(idempotent: true)— destructive, but a second delete of the same ID is a no-op (idempotent in the resource sense).site-manager/update-core→destructiveMeta(idempotent: false)— major version upgrade isn't undoable; not idempotent because the post-state depends on what's available at call time.
Get this right: AI agents calling abilities with idempotent: true will retry on transient errors automatically; calling idempotent: false ones, they ask the user before retrying. Mislabeling causes safety regressions.
6. Service layer (optional but recommended)
Mirror the plugin's src/Services/ pattern: put the actual logic in a service class, register the execute_callback as [ServiceClass::class, 'method']. Keeps registrars thin and services testable.
namespace MyCompanion\Services;
class NewsletterManager
{
public static function list_subscribers( array $input ): array
{
// ... query, paginate, return list-output-shape
return [
'subscribers' => $subscribers,
'total' => $total,
'total_pages' => $pages,
'limit' => $input['limit'],
'offset' => $input['offset'],
'has_more' => ( $input['offset'] + $input['limit'] ) < $total,
];
}
public static function create_subscriber( array $input ): array
{
// ... insert, return entity-output-shape
return [
'success' => true,
'message' => __( 'Subscriber created.', 'mycompanion' ),
'subscriber' => $subscriberArray,
];
}
public static function send_campaign( array $input ): array
{
// ... dispatch, return success-output-shape
return [
'success' => true,
'message' => sprintf( __( 'Campaign #%d queued.', 'mycompanion' ), $input['campaign_id'] ),
];
}
}
7. Document your abilities
If you ship abilities under site-manager/* for the hub, add a markdown doc per ability under your own plugin's docs/abilities/ (mirror the LW Site Manager docs/abilities/ structure). Consumers / AI agents discovering the hub catalog can then find your ability docs alongside the built-in ones.
Critical rules
- Two action hooks, one abstract class.
lw_site_manager_register_categoriesfor categories;lw_site_manager_register_abilitiesfor abilities (getsPermissionManagerinstance); extendAbstractAbilitiesRegistrarfor the helpers. - Use
$permissions->callback('can_X')for permission checks — not inlinecurrent_user_can— to keep the plugin's central permission map authoritative. - Use the helper builders for meta and schemas.
readOnlyMeta/writeMeta/destructiveMetafor annotations;paginationSchema/idSchema/listOutputSchema/entityOutputSchema/successOutputSchema/bulkResultSchemafor shapes. - Pick the right
readonly/destructive/idempotenttriple. AI agents use it for retry / undo / risk-warning logic. Mislabeling causes safety regressions. - Register category labels in
lw_site_manager_register_categoriesbefore the abilities reference them — otherwise the ability'scategoryfield points to a label-less slug and the admin grouping shows the raw slug. - Use
site-manager/namespace for abilities you want in the hub catalog. Use your own namespace if you want a separate surface. - Service layer separates registration from logic. Registrar is a thin shell;
execute_callbackpoints at a service class method. - The plugin emits NO filter hooks — additive extension only. To intercept built-in abilities, use the WP Abilities API's general lifecycle filters (out of scope for this plugin's contract).
- PHP 8.2 and WP 6.9 — the plugin's binding requirements; your companion must match.
- Don't subclass
PermissionManagerto add methods — instead, pass your own callable viapermission_callback. Subclassing breaks if the base class changes signatures.
Common mistakes
// WRONG — registering ability outside the hook (bypasses PermissionManager)
add_action( 'wp_abilities_api_init', function () {
wp_register_ability( 'site-manager/my-thing', [
'category' => 'maintenance',
'permission_callback' => static fn () => current_user_can( 'manage_options' ),
'meta' => [ 'show_in_rest' => true ],
// (forgot annotations — AI agents see ability with no readonly/destructive/idempotent hints)
// ...
] );
} );
// RIGHT — through the plugin's hook + AbstractAbilitiesRegistrar
add_action( 'lw_site_manager_register_abilities', function ( $permissions ) {
( new MyRegistrar( $permissions ) )->register();
} );
// WRONG — inline cap check
'permission_callback' => static fn () => current_user_can( 'manage_options' ),
// RIGHT — through the central PermissionManager
'permission_callback' => $this->permissions->callback( 'can_manage_options' ),
// WRONG — unannotated meta (or only show_in_rest)
'meta' => [ 'show_in_rest' => true ],
// AI agent calling this can't tell if it's safe to retry / undo / parallelize.
// RIGHT — explicit annotations via the helpers
'meta' => $this->readOnlyMeta( idempotent: true ),
// WRONG — destructive marked as readonly (or vice versa)
'meta' => $this->readOnlyMeta(), // for an ability that calls wp_delete_post()
// AI agent treats it as safe-to-retry; multiple retries delete the post once and silently succeed
// for the next N retries (which IS technically idempotent, but the readonly: true is a lie).
// RIGHT
'meta' => $this->destructiveMeta( idempotent: true ),
// WRONG — registering category in register_abilities action
add_action( 'lw_site_manager_register_abilities', function () {
wp_register_ability_category( 'mycompanion-newsletter', [/* ... */] ); // WRONG: wrong hook
// category-init has already passed; this fails or fires too late.
} );
// RIGHT — categories in their own hook
add_action( 'lw_site_manager_register_categories', function () {
wp_register_ability_category( 'mycompanion-newsletter', [/* ... */] );
} );
// WRONG — using a category that doesn't exist
'category' => 'mycompanion-newsletter', // forgot to register the category
// Ability registers but admin grouping shows the raw slug as the label.
// RIGHT — register categories first
// WRONG — bypassing the helpers and hand-rolling pagination schema
'input_schema' => [
'properties' => [
'page' => [ 'type' => 'integer' ],
'per_page' => [ 'type' => 'integer' ],
],
],
// Inconsistent with other hub abilities which use limit/offset (the plugin's convention).
// RIGHT — use listingSchema()
'input_schema' => [
'type' => 'object',
'default' => [],
'properties' => $this->listingSchema( defaultLimit: 50 ),
],
// WRONG — hand-rolled list output that doesn't match listOutputSchema's shape
return [
'data' => $items,
'count' => $total,
];
// Consumer expects { items_key, total, total_pages, limit, offset, has_more }; gets a different shape.
// RIGHT — match the helper's output envelope
return [
'subscribers' => $items,
'total' => $total,
'total_pages' => (int) ceil( $total / $limit ),
'limit' => $limit,
'offset' => $offset,
'has_more' => ( $offset + $limit ) < $total,
];
// WRONG — calling the action handler at the wrong priority / with wrong args
add_action( 'lw_site_manager_register_abilities', 'my_register' );
function my_register() { /* ... */ } // forgot the $permissions argument
// RIGHT — accept the PermissionManager arg
add_action( 'lw_site_manager_register_abilities', 'my_register', 10, 1 );
function my_register( \LightweightPlugins\SiteManager\Abilities\PermissionManager $permissions ) {
// ...
}
Cross-references
- Run
lw-site-manager-overviewfor the plugin-level reference (calling pattern, ability catalog, authentication). - Run
wp-abilities-apifor the underlying WP Abilities API mechanics —wp_register_ability, JSON Schema input/output,WP_Ability::execute(), REST run endpoint, MCP integration. LW Site Manager builds on top of that; this skill is the consumer-side wiring. - Run
wp-plugin-architecturefor the broader plugin scaffolding patterns (PSR-4 autoload, singleton scope, hook registration timing). - Run
wp-i18n-auditfor the text-domain rules on your custom abilitylabel/descriptionstrings.
What this skill does NOT cover
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 22
- Forks
- 2
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
lw-site-manager-extend-abilities- Source
- github.com/lonsdale201/wp-agent-skills