WordPress 4 September 2026 17 min read

WordPress Abilities API Best Practices: 9 Design Rules From Shipping 53 AI Tools

An ability can pass every test, satisfy its JSON Schema and have a hardened permission_callback, and still be a bad ability, because the model will not pick it, picks it for the wrong job, or calls it four hundred times in a row. We ship a commercial MCP server with 32 mail tools and up to 21 calendar tools on live customer mailboxes. Here are the nine design rules that surface bought us, each translated into runnable wp_register_ability() code.

MM
Mark McNeece Founder & Lead Developer
Glossy 3D illustration of Pip, a small white and blue robot, standing in front of a tall wall of dozens of identical unlabelled blue filing drawers with exactly one drawer pulled open, beside the headline Abilities an AI will actually use

Key Points

  • Correctness and usability are different problems: an ability can be secure, tested and schema-valid and still be one a model will not pick, or will pick for the wrong job
  • Name the user's operation, not the API endpoint. We register reply_email and cancel_meeting, not a generic update-with-headers, because a model cannot be trusted to assemble intent it was never given
  • The description is the only interface the model sees. WordPress documents it as "what the ability does and when it should be used", and almost every tutorial ignores the second half
  • Microsoft recommends no more than 25-30 tools per agent against a hard ceiling of 128; MainWP already registers 62 abilities, and we rejected a 180-tool design in August 2026
  • Take a set, not an ID. Our write tools accept up to 500 messages per call, because filing four hundred newsletters should be one call and not four hundred
  • WP_Error codes are agent-facing UI: a message that says "invalid post ID" when the real fault is a cache sends the model looking for a different ID forever
  • After an ability runs, wp-admin must look normal. Revisions intact, author preserved, no orphaned meta. The admin is the oracle, not your test suite

Almost every developer writing WordPress abilities right now has never watched a model choose between their tools under load. We have. Press Forge and Mailbox MCP are both BSolve IT Limited, and Mailbox MCP is our own commercial remote MCP server: customers connect Claude, ChatGPT or Copilot to a live mailbox, and it exposes 32 mail tools plus up to 21 calendar tools on a Microsoft 365 account. That is 53 things a model has to choose between, on somebody's real email, where a wrong choice sends a real message to a real person. Every rule below was paid for in a failure, not read in a specification.

Read the Security Post First

This is the companion to WordPress 7 Abilities API Security: Patterns Every Tutorial Skips, and that one is the prerequisite. It covers permission_callback, input and output schemas, sanitisation, meta.mcp.public and a 12-point pre-ship audit. It asks whether an ability is safe to expose. This post assumes you have done all that and asks a different question: now that it is safe, is it any good?

Where WordPress Abilities API Best Practices Stop Short

Search for WordPress Abilities API best practices and you get a page of guides that all stop in the same place: register on the right hook, namespace the ID, write a permission callback, validate the schema. That advice is correct and it is where almost every article ends. Here is the uncomfortable bit. An ability can have a hardened permission callback, a typed input schema, an output schema that leaks nothing, and full test coverage, and still be a bad ability. Not insecure. Not broken. Just bad, in three specific ways: the model never picks it, the model picks it for a job it was not built for, or the model calls it four hundred times in a row when one call would have done.

None of those show up in a test suite. Your tests call the ability directly with the arguments you chose, so they only ever prove that the ability works when it is used correctly. They say nothing about whether it will be used correctly by something that has to guess from a name and a paragraph of prose. Correctness and usability are different problems, and the second one has almost no coverage in the WordPress ecosystem right now.

We derive every number in this post rather than typing it, because a count you cannot check is a count that drifts. Here are the two commands, so you can hold us to them:

grep -A1 "server.registerTool(" packages/mcp/src/tools.ts \
  | grep -oE "^\s*'[a-z_]+'" | tr -d " '" | sort | wc -l
# 32

grep -A1 "gate.registerTool(" packages/mcp/src/calendar-tools.ts \
  | grep -oE "^\s*'[a-z_]+'" | tr -d " '" | sort | wc -l
# 21

Twenty-one is a ceiling, not a constant. A Microsoft mailbox gets all 21 calendar tools; a CalDAV server that only stores events gets 7. More on why in rule 4. Everything below was verified against WordPress 7.0 and the Abilities API as it stands in core on 4 September 2026.

1. Name the User's Operation, Not the API Endpoint

Glossy 3D illustration of Pip, a small white and blue robot, turning away from a heap of dozens of identical anonymous tools to select one clearly distinct tool from a neat rack holding a spanner, screwdriver, hammer, pliers and paintbrush, representing the difference between many interchangeable abilities and a few named ones
Mailbox MCP product artwork, in its own house style. A model does not rummage. It reads a list of names and picks one, so a rack of clearly distinct operations gets chosen correctly and a pile of near-identical ones does not.

We register reply_email. We do not register a general "send a message with these headers" tool that could, with the right arguments, produce a reply. We register cancel_meeting separately from delete_event, because cancelling a meeting you organised sends a cancellation notice to every attendee, and deleting a private block in your own diary does not. Those are different operations with different consequences, and a model cannot be trusted to know which one the argument combination it just assembled will trigger.

Same reasoning gave us check_bounces rather than expecting a model to search for messages that look like they came from a delivery subsystem, and find_contact rather than an address-book query with a filter language. Every time we replaced a general tool with a named operation, selection accuracy went up, because the model stopped having to construct intent and started having to recognise it.

Anthropic reached the same conclusion from the other direction. In their engineering guidance on writing tools for agents, Ken Aizawa puts the principle plainly:

Tools should enable agents to subdivide and solve tasks in much the same way that a human would, given access to the same underlying resources.

Ken Aizawa, Anthropic, "Writing effective tools for AI agents", 11 September 2025

What struck me about that line when I first read it is the phrase "in much the same way that a human would". It is a test you can actually apply. Nobody in your office says "I'm going to update the status field on post 4,182 to publish". They say "put Rebecca's piece live". If the ability you registered does not match a sentence somebody would actually say, you have modelled your database schema instead of the user's job, and the model will have to translate between the two on every single call. It will get that translation wrong eventually, and it will do it on the day the post it publishes is the one that was embargoed.

In WordPress terms, this is the difference between the two registrations below. Both work. Only one of them is hard to misuse.

Wrong: a generic endpoint wrapper. The model has to know that publish is a valid status, that publishing a scheduled post is different from publishing a draft, and that setting draft on a live post unpublishes it in front of an audience.

<?php
// Wrong. This is your database column with a permission check bolted on.
add_action( 'wp_abilities_api_init', function () {
    wp_register_ability(
        'pressforge/update-post-status',
        [
            'label'        => __( 'Update post status', 'pressforge' ),
            'description'  => __( 'Updates the status of a post.', 'pressforge' ),
            'category'     => 'pressforge-editorial',
            'input_schema' => [
                'type'       => 'object',
                'properties' => [
                    'post_id' => [ 'type' => 'integer' ],
                    'status'  => [ 'type' => 'string' ],
                ],
                'required'   => [ 'post_id', 'status' ],
            ],
            'execute_callback'    => 'pressforge_update_post_status',
            'permission_callback' => function ( $input ) {
                return current_user_can( 'edit_post', absint( $input['post_id'] ?? 0 ) );
            },
        ]
    );
} );

Right: one named operation, with the dangerous combinations unreachable. There is no status argument, so there is no way to ask this ability to unpublish anything. The narrowness is the feature.

<?php
if ( ! defined( 'ABSPATH' ) ) { exit; }

add_action( 'wp_abilities_api_categories_init', function () {
    wp_register_ability_category(
        'pressforge-editorial',
        [ 'label' => __( 'Editorial workflow', 'pressforge' ) ]
    );
} );

add_action( 'wp_abilities_api_init', function () {
    if ( ! class_exists( 'WP_Ability' ) ) {
        return;
    }

    wp_register_ability(
        'pressforge/publish-pending-post',
        [
            'label'       => __( 'Publish a pending post', 'pressforge' ),
            'description' => __(
                'Publishes a single post that is currently awaiting review, making it '
                . 'live on the site immediately. Use this when an editor approves a '
                . 'contributor submission. It only accepts posts in the "pending" '
                . 'status: a draft, a scheduled post or an already-published post is '
                . 'refused, so use this to approve work rather than to change a '
                . 'status generally. The post keeps its original author and its '
                . 'publish date is set to now. This is visible to the public '
                . 'straight away and is not undone by calling it again.',
                'pressforge'
            ),
            'category'     => 'pressforge-editorial',
            'input_schema' => [
                'type'       => 'object',
                'properties' => [
                    'post_id' => [
                        'type'        => 'integer',
                        'minimum'     => 1,
                        'description' => __( 'ID of the pending post to publish.', 'pressforge' ),
                    ],
                ],
                'required'             => [ 'post_id' ],
                'additionalProperties' => false,
            ],
            'output_schema' => [
                'type'       => 'object',
                'properties' => [
                    'post_id'   => [ 'type' => 'integer' ],
                    'permalink' => [ 'type' => 'string' ],
                    'title'     => [ 'type' => 'string' ],
                ],
            ],
            'execute_callback'    => 'pressforge_publish_pending_post',
            'permission_callback' => function ( $input ) {
                $post_id = absint( $input['post_id'] ?? 0 );
                return $post_id && current_user_can( 'publish_post', $post_id );
            },
            'meta' => [
                'show_in_rest' => true,
                'annotations'  => [
                    'readonly'    => false,
                    'destructive' => false,
                    'idempotent'  => true,
                ],
            ],
        ]
    );
} );

If you are building this kind of thing on a client site and want a second pair of eyes on the design before it ships, that is exactly the work our WordPress development service covers.

2. The Ability Description Is the Only Interface the Model Sees

Our tools.ts is 3,415 lines for 32 tools. That is roughly 107 lines each, and the handlers are the small part. Most of every tool is description and schema prose. That ratio looks absurd until you understand that the description is not documentation sitting next to the interface. It is the interface. The model never sees your code. It sees a name, a paragraph and a schema, and it decides from those.

Here is the real reply_email description, quoted from packages/mcp/src/tools.ts:

'Reply to a message. The reply is threaded onto the original, the original is quoted below the
new text the way any mail client quotes it, a copy is filed in Sent, and the original is marked
as answered so it shows a reply arrow in Outlook or webmail. Recipients are taken from the
original (Reply-To if it set one, otherwise From) and never include this mailbox itself -
use `cc` to copy in anyone who was not already on the thread, exactly as you would type into
the Cc line of a reply. Files can be attached with `attachments`. If the message carries a
Reply-To pointing somewhere it did not come from, this tool REFUSES until you name that
address in `confirmReplyTo` - read the refusal and tell the user where the reply is really
going. This delivers real mail to real people and cannot be undone.'

Every clause in there exists because a model got something wrong without it. The line about recipients never including this mailbox is there because a model once cheerfully added the user's own address to a reply. The Reply-To warning is there because a phishing message can carry a Reply-To pointing at an attacker, and a model that does not know to check will happily send the reply there. None of that is decoration. It is a list of scars.

WordPress already asks for this, and hardly anyone does it. The Code Reference defines the description argument as "A detailed description of what the ability does and when it should be used." Read the second half of that sentence again. And when it should be used. Every "complete guide" on page one of Google demonstrates the API with descriptions like "Returns basic site information", which answers the first half and ignores the second, and the second half is the one that determines whether the model picks your ability over the four others that also sound plausible.

Before. Grammatically fine, operationally useless:

'description' => __( 'Updates a post.', 'pressforge' ),

After. Says what happens, what does not happen, what it refuses, and when to reach for something else:

'description' => __(
    'Replaces the body content of a single draft post, leaving its title, '
    . 'author, categories, tags and featured image untouched. A revision is '
    . 'saved first, so the previous version stays recoverable from the editor. '
    . 'Refuses any post that is already published: use '
    . 'pressforge/publish-pending-post to put work live, and never use this to '
    . 'edit live content. The post stays a draft afterwards. Nothing is visible '
    . 'to the public as a result of this call.',
    'pressforge'
),

The rule of thumb we ended up with: write the description for a competent new starter who cannot see your code, will not ask you a question, and has to choose between your ability and every other one on the site in about a second.

3. Annotate the Safety Class: readonly, destructive, idempotent

Glossy 3D illustration of Pip, a small white and blue robot, resting one hand on a small open lever on a control console beside a larger lever and a third heavy lever sealed under a hinged transparent safety cover, representing read, reversible and destructive abilities
Mailbox MCP product artwork, in its own house style. Three levers, escalating protection. The guard on the third one is what an accurate destructive annotation buys you: a client that stops and asks before it pulls.

Deriving the split across our 32 mail tools from their annotations rather than counting by hand gives 12 read, 9 reversible and 11 destructive. The read tools carry readOnlyHint: true. The reversible ones (drafts, flags, folder creation, marking read) carry destructiveHint: false. The destructive 11 are the ones that send mail, delete things, move things between folders, or rename a folder out from under whatever was referencing it. Archiving and junking are in that group too, and that is the annotation earning its keep: both sound gentle and both are a move, so a tool that files a message into Junk is marked exactly as riskily as the one that moves it by name.

Those annotations decide whether a host application stops and asks the user before running the tool. That is their entire job, and it is why an inaccurate one is worse than a missing one: it actively removes a confirmation prompt that should have appeared.

One case from our own codebase is worth showing, because it is the one where the obvious answer is wrong. create_upload_link changes nothing at the moment it is called. It just mints a link. So it looks read-only, and we wrote the reasoning out in the file to stop a future maintainer "fixing" it:

/* NOT read-only: it mints a capability that can write into the
   mailbox. Nothing changes at the moment of the call, which is the
   argument for `readOnlyHint: true`, and that argument is wrong -
   the annotation is what a client shows a user before auto-approving,
   and "this creates a link that can add files to your mail" is
   exactly what they should be shown. */

WordPress uses the same three-word vocabulary, under meta.annotations. The mapping is direct:

ClassAnnotationWhat a client should doWordPress example
Readreadonly => trueRun without askingFetch a post's SEO score
Reversiblereadonly => false, destructive => falseRun, but say what it didSave a draft, add a tag
Destructivedestructive => trueStop and ask the user firstDelete a post, publish, send email

Two traps worth naming. First, idempotent means calling it twice has no additional effect, and most writes are not idempotent, full stop. Publishing a pending post is idempotent (the second call finds nothing pending and refuses). Appending a comment is not. Second, "reversible" means reversible by the user, in wp-admin, without your help. If undoing it needs a database query, it is destructive.

4. Gate at Registration, Not at Call Time

Our calendar tools are wrapped in a gate that refuses to register a tool the underlying provider cannot honour. It also throws if an adapter claims a capability with no method behind it. The error message is the argument:

if (calendar.capabilities[CAPABILITY_OF_METHOD[method]] !== true) return undefined;
if (typeof calendar[method] !== 'function') {
    throw new Error(
        `this calendar reports ${CAPABILITY_OF_METHOD[method]} but implements no ${method}(), so ` +
            `${name} would fail the first time anybody called it`
    );
}

That last clause is the whole rule. A tool that fails the first time a customer calls it is worse than a tool that was never offered, because by then the model has already told the user it can do the thing, and probably built a plan around it. This is why our calendar count is a ceiling: a Microsoft mailbox gets all 21, a CalDAV server with RFC 6638 scheduling gets 13, one that only stores events gets 7. The customer is told the truth up front rather than discovering it mid-task.

Here is a live counter-example, and it is worth being precise about because the plugin in question is a good one. MainWP's Abilities API documentation describes feature gating where abilities depending on optional modules (Cost Tracker, Logs) remain registered, and return a mainwp_module_not_available error when the module is inactive. That is gating at call time. It is a perfectly reasonable engineering choice, and for a dashboard where the user can go and switch the module on it may be the right one. But it means the model is shown a capability that does not currently exist, and the only way it can find out is to try.

The WordPress version of registration-time gating is unremarkable code. Your registration callback is just a function, so anything you can test there can decide whether the ability is offered at all:

<?php
if ( ! defined( 'ABSPATH' ) ) { exit; }

add_action( 'wp_abilities_api_init', function () {
    // The registry only exists from WordPress 6.9 onwards.
    if ( ! class_exists( 'WP_Ability' ) ) {
        return;
    }

    // Do not promise WooCommerce work on a site without WooCommerce.
    if ( ! class_exists( 'WooCommerce' ) ) {
        return;
    }

    // Do not promise a feature the site owner has switched off.
    if ( 'yes' !== get_option( 'pressforge_enable_order_abilities', 'no' ) ) {
        return;
    }

    wp_register_ability(
        'pressforge/refund-order',
        [
            'label'       => __( 'Refund a WooCommerce order', 'pressforge' ),
            'description' => __(
                'Issues a full refund against a single completed WooCommerce order '
                . 'through the original payment gateway, and moves the order to '
                . 'refunded. The money leaves the merchant account immediately and '
                . 'this cannot be undone from WordPress. Refuses orders that are '
                . 'not completed, and refuses partial amounts.',
                'pressforge'
            ),
            'category'            => 'pressforge-shop',
            'input_schema'        => [
                'type'       => 'object',
                'properties' => [
                    'order_id' => [ 'type' => 'integer', 'minimum' => 1 ],
                ],
                'required'   => [ 'order_id' ],
            ],
            'execute_callback'    => 'pressforge_refund_order',
            'permission_callback' => function () {
                return current_user_can( 'manage_woocommerce' );
            },
            'meta' => [
                'show_in_rest' => true,
                'annotations'  => [
                    'readonly'    => false,
                    'destructive' => true,
                    'idempotent'  => false,
                ],
            ],
        ]
    );
} );

A registered ability is a promise. Only make promises the site can keep at the moment it makes them.

5. How Many Abilities Should a Plugin Register?

On 25 August 2026 we decided a real design question: a customer with twelve mailboxes and fifteen tools each. Do they get 180 tools, or fifteen tools with a mailbox argument, or twelve connectors of fifteen? We chose twelve connectors, one per mailbox, and the tool-list entry in that decision record reads: "Stays at fifteen. Some clients truncate long tool lists, and 180 tools would be both truncated and unusable to a model choosing between them."

The safety argument mattered more, and it is the same argument as rule 1 at a different scale. With a mailbox argument, the model chooses which account the mail is sent from. For an email product that is the worst failure available: replying from the wrong identity, moving a client's thread into a personal account. Putting the mailbox on the connection removes the entire class of error, because there is no argument left to get wrong.

You do not have to take our word for the tool-count part. Microsoft publishes a hard number and a soft one for Copilot Studio:

When using generative orchestration, the orchestrator can handle a maximum of 128 tools per agent. For best performance and results, however, we recommend limiting the number of tools in your agent to no more than 25-30.

Microsoft Learn, "Add tools to custom agents", updated 2026

The gap between those two numbers is the interesting part. Microsoft's platform will accept 128 and their own guidance says to stay under a quarter of that. In other words, the technical limit and the usable limit are not the same thing, which is exactly the distinction this whole post is about. And note the budget is per agent, not per plugin. Your abilities share that context with everything else the user has connected. If a site runs three plugins that each register twenty abilities, none of them individually did anything unreasonable, and the agent is now over the line.

For scale, MainWP registers 62 abilities across five categories. For a dedicated multi-site management dashboard, where managing sites is the entire point of connecting the agent, that is a defensible call. As a model for a general-purpose plugin sharing an agent with others, it is not one I would copy.

The research backs the direction. The RAG-MCP paper (Gan and Sun, arXiv:2505.03275) measured tool selection accuracy at 13.62% when the full tool list was placed in the model's context, rising to 43.13% when only relevant tools were retrieved first. Anthropic's own evaluation of large tool libraries reports Opus 4 improving from 49% to 74% with tool search enabled. Different setups, same finding: the tool list is not free, and every entry you add makes every other entry slightly harder to find.

So if you have thirty candidate abilities, you have three options and only one of them is "register thirty":

  • Consolidate. Five abilities that differ only by a status value are one ability with an enum, or better, one named operation per distinct user intent.
  • Gate by context, per rule 4. An ability the current user cannot use, on a feature that is switched off, should not be in the list at all.
  • Split the surface. If two groups of abilities serve two unrelated jobs, that may be two plugins, or an opt-in setting, rather than one list nobody can navigate.

6. Take a Set, Not an ID, When an Agent Would Otherwise Loop

Glossy 3D illustration of Pip, a small white and blue robot, lowering an entire fanned spread of blank white index cards into an open card index drawer in one single motion, representing an ability that accepts an array of IDs instead of forcing an agent to loop
Mailbox MCP product artwork, in its own house style. One motion, the whole fan. If the ability only took one card, the agent would make the same trip four hundred times and nobody would thank it.

Our write tools take up to 500 message UIDs in a single call. The constant is MAX_BATCH in packages/engine/src/batch.ts, and when you exceed it the refusal tells you the limit rather than failing vaguely. The reason is simple: filing four hundred newsletters into a folder should be one call, not four hundred.

Give an agent a single-ID tool and a task that plainly applies to many items, and it will loop. It will not get bored, it will not batch on your behalf, and it will not stop to wonder whether four hundred sequential HTTP requests is a sensible way to spend the next six minutes. On a WordPress site those calls each carry a full bootstrap, so the cost lands on the server rather than the agent.

The fix is to accept an array with a declared maximum, and to return a per-item result so one bad ID does not read as total failure:

<?php
if ( ! defined( 'ABSPATH' ) ) { exit; }

add_action( 'wp_abilities_api_init', function () {
    if ( ! class_exists( 'WP_Ability' ) ) {
        return;
    }

    wp_register_ability(
        'pressforge/publish-pending-posts',
        [
            'label'       => __( 'Publish pending posts', 'pressforge' ),
            'description' => __(
                'Publishes up to 50 pending posts in a single call. Use this whenever '
                . 'more than one post is being approved, rather than calling the '
                . 'single-post ability repeatedly. Each post is reported on '
                . 'individually, so some can succeed while others are refused, and a '
                . 'refusal never stops the rest. Posts that are not pending are '
                . 'skipped and reported, not treated as errors.',
                'pressforge'
            ),
            'category'     => 'pressforge-editorial',
            'input_schema' => [
                'type'       => 'object',
                'properties' => [
                    'post_ids' => [
                        'type'        => 'array',
                        'items'       => [ 'type' => 'integer', 'minimum' => 1 ],
                        'minItems'    => 1,
                        'maxItems'    => 50,
                        'description' => __( 'IDs of the pending posts to publish.', 'pressforge' ),
                    ],
                ],
                'required'             => [ 'post_ids' ],
                'additionalProperties' => false,
            ],
            'execute_callback'    => 'pressforge_publish_pending_posts',
            'permission_callback' => function () {
                return current_user_can( 'publish_posts' );
            },
            'meta' => [
                'show_in_rest' => true,
                'annotations'  => [ 'readonly' => false, 'destructive' => false, 'idempotent' => true ],
            ],
        ]
    );
} );

/**
 * Publish a batch of pending posts, reporting on each one separately.
 *
 * @param array $input Validated input.
 * @return array|WP_Error
 */
function pressforge_publish_pending_posts( $input ) {
    $post_ids = array_map( 'absint', (array) ( $input['post_ids'] ?? [] ) );
    $results  = [];

    foreach ( $post_ids as $post_id ) {
        $post = get_post( $post_id );

        if ( ! $post ) {
            $results[] = [
                'post_id' => $post_id,
                'status'  => 'not_found',
                'message' => 'No post exists with this ID.',
            ];
            continue;
        }

        if ( ! current_user_can( 'publish_post', $post_id ) ) {
            $results[] = [
                'post_id' => $post_id,
                'status'  => 'forbidden',
                'message' => 'This user cannot publish this post.',
            ];
            continue;
        }

        if ( 'pending' !== $post->post_status ) {
            $results[] = [
                'post_id' => $post_id,
                'status'  => 'skipped',
                'message' => sprintf(
                    'Post is "%s", not "pending", so it was left alone.',
                    $post->post_status
                ),
            ];
            continue;
        }

        wp_update_post( [ 'ID' => $post_id, 'post_status' => 'publish' ] );

        $results[] = [
            'post_id'   => $post_id,
            'status'    => 'published',
            'permalink' => get_permalink( $post_id ),
        ];
    }

    return [ 'results' => $results ];
}

Note what the skipped and forbidden branches do. They report, in words, and carry on. That matters for the next rule.

7. WP_Error Messages Are Agent-Facing UI

Our worked example here is a bug we shipped. On 3 September 2026, updateDraft went out without calling client.noop() after taking its mailbox lock. Every other read path does this, for a reason recorded in the project's own CLAUDE.md:

**Every read path calls `client.noop()` after taking its lock, before any FETCH.**
`getMailboxLock` takes a fast path when the mailbox is already selected, so the
FETCH runs against a view that predates anything appended since - the message is
simply absent, and the error names the uid, so it reads as "the caller passed a
bad uid" rather than "we did not look properly".

Sit with that failure mode, because it is nastier than it first appears. The code was wrong, but the error it produced blamed the caller. A model handed "no message with UID 4182" does not conclude that the server looked in a stale view. It concludes it had the wrong ID, and goes looking for a better one. Then another. The bug survived every fake-server test we had, because a fake has no selected-mailbox state to go stale, and it was caught only by a live test that appends on a second connection first, which is also the real-world case: somebody composing in Outlook while asking us to edit it.

AWS put the general principle well. Daniel Wells and Raian Osman, writing on the AWS Machine Learning blog about MCP tool design, contrast two responses to the same failed call:

A response that says "search requires 2 or more terms in query" tells the LLM exactly what to change. A response that returns only "no results" gives it nothing, causing the model to either give up or keep guessing.

Daniel Wells and Raian Osman, AWS, "MCP tool design: Practical approaches and tradeoffs", 9 July 2026

"Give up or keep guessing" is precisely the pair of behaviours we saw, and reading it was the point at which I stopped thinking of error strings as diagnostics. A human reading "no results" opens a terminal and investigates. A model reading it has only two moves available, and both are bad. The error message is the last instruction you give the agent before it decides what to do next, so it is worth as much care as the description.

In WordPress that means the WP_Error code carries the meaning and the message carries the fix. Distinguish these four cases, always, and never collapse them:

<?php
/**
 * Return the four failure cases as distinct, actionable errors.
 *
 * @param array $input Validated input.
 * @return array|WP_Error
 */
function pressforge_publish_pending_post( $input ) {
    $post_id = absint( $input['post_id'] ?? 0 );
    $post    = get_post( $post_id );

    // 1. The object does not exist at all. Looking for another ID is sensible.
    if ( ! $post ) {
        return new WP_Error(
            'pressforge_post_not_found',
            sprintf( 'No post exists with ID %d. Check the ID, or list pending posts first.', $post_id ),
            [ 'status' => 404 ]
        );
    }

    // 2. It exists and this user may not touch it. A different ID will NOT help.
    if ( ! current_user_can( 'publish_post', $post_id ) ) {
        return new WP_Error(
            'pressforge_cannot_publish',
            sprintf(
                'Post %d exists, but this user does not have permission to publish it. '
                . 'Do not retry with a different ID: ask the site owner for the '
                . 'publish_posts capability.',
                $post_id
            ),
            [ 'status' => 403 ]
        );
    }

    // 3. It exists, is allowed, but is in the wrong state. Name the state.
    if ( 'pending' !== $post->post_status ) {
        return new WP_Error(
            'pressforge_not_pending',
            sprintf(
                'Post %d is "%s", not "pending", so there is nothing to approve. '
                . 'If you meant to edit it, use pressforge/update-draft-content.',
                $post_id,
                $post->post_status
            ),
            [ 'status' => 409 ]
        );
    }

    $result = wp_update_post( [ 'ID' => $post_id, 'post_status' => 'publish' ], true );

    // 4. Our fault, and say so, so the agent stops blaming its own arguments.
    if ( is_wp_error( $result ) ) {
        return new WP_Error(
            'pressforge_publish_failed',
            'The post could not be published because of a server-side error. '
            . 'The arguments were valid. Report this rather than retrying.',
            [ 'status' => 500 ]
        );
    }

    return [
        'post_id'   => $post_id,
        'permalink' => get_permalink( $post_id ),
        'title'     => get_the_title( $post_id ),
    ];
}

The fourth branch is the one people leave out, and it is the one that maps directly onto our own bug. When the fault is yours, say so in the message. Otherwise the agent assumes it is holding the tool wrong and keeps trying new grips. Getting this right on a live site is fiddly to test, which is part of what our WordPress support and maintenance work covers on the sites we look after.

8. A Read Ability Must Not Write

Reading a message through Mailbox MCP never sets the \Seen flag. Marking mail read is its own tool, and it runs when the person asks for it. This sounds pedantic until you picture the alternative: you ask your assistant to check whether anything urgent came in overnight, and it silently marks forty messages as read, and now you cannot tell which ones you have actually dealt with. The user's mental model of their own inbox has been quietly corrupted by a read operation.

The newest tool on the surface is the cleanest test of this rule we have. read_attachment shipped on 12 September 2026 and opens the files a message carries, by the refs read_email lists: a PDF comes back as text with page markers, a spreadsheet as one CSV block per sheet with its formulas already worked out, a Word document or a slide deck as text, and a photo as a picture the model can look at. A scanned page comes back as an image of that page, up to four pages per call, so the model reads it the way you would. A zip, an RTF or an old-format .doc is named rather than opened, and the person still has the download link. Every one of those reads has to satisfy the same three conditions as reading the message itself: nothing is marked read, nothing is stored, and the file never leaves the mailbox. The tool carries readOnlyHint: true, and per rule 3 that annotation is a promise a client will auto-approve on. There is one more rule that only becomes obvious once you can read files: the text inside an attachment is as untrusted as the message it arrived with. An instruction found in a PDF is something to report to the user, not something to act on, and the tool's own description says so in capitals, for the same reason the reply_email description in rule 2 warns about Reply-To. Getting files in and out of that surface without the model having to encode them is its own design problem, and we have written up the four attachment routes it took separately.

WordPress has the same trap with more ways to fall into it, because the write can come from code you did not write. Things that turn a read into a write:

  • wp_update_post() anywhere in a read path. It bumps post_modified and can create a revision even when nothing changed. If post_modified feeds your sitemap, you have just told Google every post changed today.
  • update_post_meta() for view counters or "last accessed" timestamps.
  • wp_set_object_terms() called to tidy taxonomies while reading.
  • Anything hooked to the_content, the_post or wp that writes. An SEO or analytics plugin listening there will happily create the revision your own code was careful not to.
  • Transient or cache warming that writes to wp_options on every call, which on a busy agent loop is a genuine performance problem rather than a tidiness one.

If your read ability must record that it ran, log it somewhere that is not the object being read, and set readonly => true only if you have checked what your hooks do. The honest test is to note post_modified and the revision count, run the ability, and compare. If either moved, the annotation is a lie, and per rule 3 a client is now auto-approving something it should have asked about.

9. wp-admin Is the Oracle

Glossy 3D illustration of Pip, a small white and blue robot, stepping back with a satisfied thumbs-up beside a tall cabinet of filing drawers that are all perfectly closed and aligned with nothing left out of place, representing an ability that leaves wp-admin exactly as it found it
Mailbox MCP product artwork, in its own house style. The finished job leaves no trace that anyone was there. That is the standard an ability has to meet, and no test suite can confirm it for you.

The rule at the top of the Mailbox MCP project documentation is one sentence, and it governs everything else in this post:

A user must not be able to tell, from Outlook or webmail, that a message was handled by us rather than by them. Not our API responses, not a green test run: the mailbox opened in another program is what decides whether a slice works.

The reason it exists is that the convenient implementation is reliably the wrong one, and the damage is invisible to us and obvious to the user weeks later, somewhere else entirely. A send files a Sent copy exactly once. A delete moves to Trash rather than destroying. A draft is appended so another client can still edit it. A move preserves flags and the internal date. A bug that files no Sent copy is invisible to a test written against the same engine that failed to file it, which is why acceptance ends in a mail client rather than in a terminal.

Translate it directly and you get the closing argument of this post. After your ability runs, wp-admin must look normal. Not "the function returned true". Not "the tests are green". Open the admin and look:

  • Revisions intact and in the right order, with no surprise revision from a read.
  • The original author preserved, not replaced by whoever's application password the agent used.
  • post_modified only moved if something actually changed.
  • No orphaned postmeta left behind by a half-finished operation.
  • Scheduled posts still scheduled, with their dates untouched.
  • No post left in a state an editor cannot get out of through the normal interface.
  • Media library items still attached to the posts that use them.

An ability that leaves the admin looking wrong has failed, even if it returned a 200 and every test passed. The person who finds it will not be you and it will not be today. It will be an editor, three weeks later, wondering why the post they scheduled for Tuesday went out on Friday under somebody else's name, and there will be nothing in your logs to explain it. That is the same shape of failure as an unnoticed missing Sent copy, and it costs the same thing: the user's trust that the system does what they think it does.

MCP Tools vs WordPress Abilities: Where This Strains

Four honest caveats, because a post arguing for accurate descriptions should be accurate about itself.

These are lessons from a mail product, not from WordPress. The failure modes are not identical. Our MCP tools run behind a network boundary with exactly one credential and one mailbox, and the blast radius of a mistake is bounded by that. WordPress abilities run in-process, in a shared PHP request, alongside every other plugin on the site, with hooks that can rewrite what your code does after you have written it. Rule 8 is much harder to guarantee in WordPress than in our world, and rule 4 is easier. Do not assume a lesson transfers just because the shape rhymes.

The Abilities API is young and moving. It landed in core in WordPress 6.9 in December 2025, and the client-side pieces arrived with 7.0 in May 2026. Everything here was verified against core as it stands on 4 September 2026. Check the current function reference before copying any signature out of any blog post, including this one.

We have not shipped sixty abilities on a client site. Our evidence is an MCP surface of 32 mail tools and up to 21 calendar tools that real customers use daily, plus published guidance and research from Anthropic, AWS, Microsoft and the RAG-MCP authors. That is a strong basis for the design principles and it is not the same as having run a 60-ability WordPress plugin in production for a year. Nobody has done the latter yet, because the API is nine months old.

Some of this is opinion informed by experience rather than measurement. The tool-count numbers in rule 5 are measured and cited. The claim in rule 1 that named operations get picked more reliably than general ones is our observation across our own tool surface, not a controlled experiment, and I have flagged it in the sentence rather than dressing it up. Rule 9 is a value judgement about what we owe users, and I would argue for it even if it cost us something.

If you want to see all nine applied to a working surface rather than described, the Mailbox MCP tool list and its feature documentation are the actual product, and the write-up on connecting an AI assistant to your email and calendar covers what it looks like from the user's side. On the WordPress side, our posts on the WordPress 7.0 AI engine and its missing spend limit and on Block Bindings as a single source of truth cover the two other core pieces this one sits alongside, and 365i's hands-on with the AI connectors in the 7.0 beta is where we first tested the plumbing underneath all of it. If you are weighing up whether to expose any of this to an agent at all, the security post is still the one to read before you ship.

Frequently Asked Questions

Why does my AI agent pick the wrong ability?

Almost always because two abilities read as interchangeable to the model, or because the description says what the ability does without saying when to use it. A model chooses from names, descriptions and schemas, not from your code. Give each ability a name that states a distinct user operation, and add a sentence to each description saying which neighbouring ability to prefer in the cases this one does not cover. If you have thirty abilities and several overlap, the fix is consolidation, not a longer description.

How many abilities should a WordPress plugin register?

Fewer than feels natural. Microsoft Copilot Studio handles a maximum of 128 tools per agent but recommends no more than 25 to 30 for best results, and that budget covers every tool the user has connected, not just yours. MainWP registers 62 abilities, which is a reasonable choice for a dedicated management dashboard but would be a poor one for a general plugin sharing an agent with others. If you are past about a dozen, consolidate related operations or gate them by context.

Do ability descriptions actually affect what an AI agent does?

Yes, and they are the main lever you have. The description is loaded into the model's context and is what it reasons over when choosing. WordPress documents the description argument as "a detailed description of what the ability does and when it should be used", and the second half of that sentence is the part most tutorials skip. Every side effect a caller cannot see, every precondition, and every case where a different ability is the better choice belongs in the description.

Should an ability accept an array of post IDs instead of one?

Where an agent would otherwise loop, yes. If a plausible request is "do this to everything in the list", a single-ID ability guarantees the model runs the same call repeatedly, once per item, with all the latency and failure surface that implies. Accept an array, declare maxItems in the input schema so the bound is enforced rather than hoped for, and return a per-item result so a partial failure does not read as total failure.

What is the difference between meta.annotations.readonly and permission_callback?

They solve different problems. permission_callback decides whether the call is allowed to happen at all and is your security boundary. The annotations are advisory metadata that tell a client how to present the ability, including whether to stop and ask the user for confirmation before running it. An accurate annotation does not make an ability safe, and a hardened permission_callback does not stop a client auto-approving a destructive ability that claims to be read-only.

Can I register a WordPress ability conditionally?

Yes, and you usually should. wp_register_ability() runs inside your callback on the wp_abilities_api_init hook, so any condition you can evaluate at that point can decide whether the ability is offered: whether a dependency plugin is active, whether a feature is switched on, whether the required database table exists. Registering an ability that cannot work and letting it fail on first call is worse, because the model has already been told the capability exists and has planned around it.

Which WordPress functions accidentally write during a read?

wp_update_post() is the obvious one, and it bumps post_modified and can create a revision even when the content is unchanged. Beyond that, watch update_post_meta() used for view counters, wp_set_object_terms() called to normalise taxonomies during a read, and any code hooked to the_content or the_post that writes. Third-party plugins are the usual culprit: an SEO or analytics plugin listening on your read path can create the revision your own code carefully avoided.

Does the WordPress Abilities API need the MCP Adapter to work with AI agents?

For external AI agents, yes. The Abilities API is a registry that lives in WordPress core from 6.9 onwards and can expose abilities over REST when meta.show_in_rest is true. Translating those abilities into Model Context Protocol tools that Claude, ChatGPT or Copilot can discover and call is the job of the separate WordPress MCP Adapter package. The design rules in this post apply either way, because both surfaces present your name, description and schema to something that has to choose.

Building Something an Agent Will Call?

We build custom WordPress plugins for UK businesses, and we ship a commercial MCP server of our own, so we review ability surfaces the way we review our own tool list: not just is it safe, but will a model use it correctly on the day it matters. If you have abilities going live this quarter, we can look at them before an agent does.

WordPress Development Services

Sources