# 🛡️ Building AbilityGuard: Monitoring the WordPress Abilities API in Production

> Source: <https://dev.to/kushang_tailor/building-abilityguard-monitoring-the-wordpress-abilities-api-in-production-3mpe>
> Published: 2026-07-30 15:20:05+00:00

A few weeks ago I wrote about the WordPress Abilities API — what it is, why WordPress 6.9 shipped it, and what it means for how plugins will talk to each other and to AI agents going forward. That post was theory. This one is the part where theory meets a `composer.json`

file and a stubborn bug at 1 AM.

This is the story of building **AbilityGuard** — a plugin that monitors Abilities API usage in production, so you actually know what's happening when abilities get registered, called, and (occasionally) abused.

Here's the thing about the Abilities API that got me nervous the first time I really understood it: it's a *capability surface*. Any plugin can register an ability. Any authorized caller — a human-triggered action, an automation, or increasingly, an AI agent — can invoke one. That's the whole point of the API, and it's genuinely exciting. But it also means your site now has a growing list of "things that can be done to it programmatically," and most WordPress admins have zero visibility into that list.

I've spent enough years debugging WordPress sites in production to know what happens when you can't see something: you find out about it during an incident, not before. Slow queries, rogue cron jobs, plugin conflicts — they all follow the same pattern. Nobody notices the small thing until the small thing becomes the outage.

So the idea for AbilityGuard was simple: give site owners a dashboard and a log for every ability registered on their site, every time one gets called, and by whom. Not another abstract "security scanner" — just honest, readable visibility into a part of WordPress that's brand new and mostly invisible right now.

The Abilities API exposes a central registry (`wp_get_ability_registry()`

under the hood, with helper functions layered on top). My first instinct was to intercept ability calls by wrapping core functions — and I killed that idea within the hour. Wrapping core functions is exactly the kind of thing that breaks on the next minor release and turns your plugin into the villain of someone else's changelog.

Instead, AbilityGuard hooks into the lifecycle events the API already exposes: registration, before-execution, and after-execution actions. That's the whole trick. I'm not intercepting anything — I'm listening. When an ability is registered, I capture its metadata (name, description, input/output schema, permission callback). When it's executed, I capture the caller context, the arguments passed, the execution time, and the result status.

That distinction — listening instead of wrapping — ended up shaping almost every architectural decision that followed.

I could get technical here, but honestly, the idea behind the build is simple enough to explain without any code.

Think of AbilityGuard as three small, separate jobs living under one roof, and none of them stepping on each other's toes:

For a monitoring tool, that predictability matters more than clever code. A plugin whose entire job is to watch quietly in the background should never be the thing that causes a problem.

This is where AbilityGuard gets a little more interesting than a typical CRUD plugin, because the "input" isn't a form field a human filled in — it's arbitrary arguments passed to an ability, possibly by an automated caller. I can't assume anything about shape or intent.

Every argument captured for logging goes through explicit validation against expected types before it's ever sanitized or stored — nothing gets written on faith. Output is escaped at render time, not at storage time, which is the rule I see broken most often in plugins I audit: people sanitize once on the way in and assume that protects them on the way out. It doesn't. Two different jobs, two different moments.

```
// LogRepository.php (simplified)
public function insert_event( array $event ): int {
    $sanitized = array(
        'ability_name' => sanitize_key( $event['ability_name'] ),
        'trigger_type' => sanitize_text_field( $event['trigger_type'] ), // rest, wp-cli, cron, php
        'status'       => in_array( $event['status'], array( 'success', 'failure', 'denied' ), true )
            ? $event['status']
            : 'unknown',
        'input'        => $this->logging_enabled( 'input' ) ? wp_json_encode( $event['input'] ) : null,
        'output'       => $this->logging_enabled( 'output' ) ? wp_json_encode( $event['output'] ) : null,
        'created_at'   => current_time( 'mysql', true ),
    );

    return (int) $this->wpdb->insert( $this->table_name, $sanitized );
}
```

Two decisions there came straight out of thinking about what an admin would actually want, and what they'd regret leaving on by accident. Input logging is configurable, and output logging is **off by default**, because ability responses can carry sensitive or genuinely large payloads and I'd rather someone opt in deliberately than discover a log table quietly filling up with data they didn't mean to store. On top of that, the log itself is a rolling window of the latest 100 execution entries rather than an ever-growing table — visibility without turning into its own storage liability. And none of it leaves the site: everything stays in a single custom table, `{$wpdb->prefix}ability_guard_logs`

, with no data sent to an external service.

Two things I didn't expect going in.

First, how much discipline it took to *not* scope-creep into a general activity log. It would have been easy to also log post edits, media uploads, settings changes — the usual audit-log territory. I deliberately left all of that out. AbilityGuard only logs what runs through a registered ability's native execution hooks; if an action doesn't go through the Abilities API, AbilityGuard stays quiet about it. That restraint is the whole point — it's a focused lens on one new capability surface, not a replacement for a general audit log plugin.

Second, how much the "listener, not wrapper" decision simplified everything downstream. Because `ExecutionListener`

only hooks into native ability execution hooks instead of patching internals, trigger context — REST, WP-CLI, cron, or plain PHP — falls out of the hook data for free. I didn't have to reconstruct *how* something ran; the API already tells me.

The next thing on my list is extending AbilityGuard toward accessibility auditing — using the same "listen, don't wrap" pattern to flag abilities whose responses might affect assistive technology output. That's still early, but it feels like a natural second act for a plugin whose whole reason for existing is *making the invisible visible*.

If you're building on the Abilities API yourself — whether that's registering your own abilities or just trying to understand what's already running on your site — I'd genuinely like to hear what you're building. This corner of WordPress is still small enough that we're all figuring it out together.

🔗 **AbilityGuard is live on WordPress.org:** [https://wordpress.org/plugins/ability-guard/](https://wordpress.org/plugins/ability-guard/)
