Giving a language model access to your WordPress site is easy. Giving it access that you can defend in a security review is not. The usual shortcut — a custom REST route that returns whatever the model asks for — fails on three counts: there is no machine-readable contract, no per-call capability check, and no ceiling on how much data leaves the site.
The Abilities API fixes all three. An ability is a named, schema-described unit of work with its own permission_callback and execute_callback. WordPress validates input against the schema before your code runs, and you can publish the same schema to an LLM as a tool definition. This tutorial registers three read-only abilities — search posts, fetch a post by ID, list categories — then wires them into a server-side dispatch loop with an allowlist and output truncation.
Why read-only abilities beat a bespoke REST endpoint
Three properties make abilities a better boundary than a hand-rolled route:
- The schema is enforced, not documented.
WP_Ability::execute()validates input againstinput_schemaand returns aWP_Erroron mismatch. A hallucinatedpost_typeofrevisionnever reaches your query. - Permissions travel with the ability. The callback runs on every invocation, including when you call the ability from PHP. There is no path that skips it.
- Annotations are machine-readable. Setting
readonly => trueinmeta.annotationslets a UI or an agent runtime decide what to auto-approve and what to confirm.
The Abilities API ships in WordPress 6.9 and is developed in the Gutenberg plugin. If you are on an earlier version, install Gutenberg and guard your registrations with function_exists( 'wp_register_ability' ) so the plugin degrades instead of fataling.
Prerequisites
- WordPress 6.9+ (or the Gutenberg plugin on 6.7+), PHP 7.4+, and a staging site you can break.
- An LLM provider that supports function/tool calling. The examples use an OpenAI-compatible chat completions endpoint; swap the URL and payload for Anthropic, Mistral or a local Ollama instance.
- An API key stored in
wp-config.phpas a constant — never in the database, never in a plugin file. - Familiarity with
WP_Query,get_terms()and the REST API.
Step 1 — Register a category and three abilities
Categories group abilities for discovery and for UI. Both categories and abilities must be registered on their dedicated hooks — wp_abilities_api_categories_init and wp_abilities_api_init — because the registry is not ready earlier. Registering on init triggers a _doing_it_wrong() notice.
<?php
/**
* Plugin Name: Read-Only AI Abilities
* Description: A small, read-only ability surface for an LLM agent.
*/
defined( 'ABSPATH' ) || exit;
add_action( 'wp_abilities_api_categories_init', 'roa_register_categories' );
function roa_register_categories() {
wp_register_ability_category(
'content-read',
array(
'label' => __( 'Content (read-only)', 'roa' ),
'description' => __( 'Read-only access to published posts and taxonomies.', 'roa' ),
)
);
}
Now the first ability. Note the name format: a lowercase namespace, a slash, a lowercase slug. Anything else is rejected at registration.
add_action( 'wp_abilities_api_init', 'roa_register_abilities' );
function roa_register_abilities() {
wp_register_ability(
'roa/search-posts',
array(
'label' => __( 'Search posts', 'roa' ),
'description' => __( 'Full-text search across published posts. Returns at most 10 trimmed results with id, title, excerpt, url and modified date.', 'roa' ),
'category' => 'content-read',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'query' => array(
'type' => 'string',
'minLength' => 2,
'maxLength' => 120,
'description' => 'Search terms.',
),
'post_type' => array(
'type' => 'string',
'enum' => array( 'post', 'page' ),
'default' => 'post',
),
'limit' => array(
'type' => 'integer',
'minimum' => 1,
'maximum' => 10,
'default' => 5,
),
),
'required' => array( 'query' ),
'additionalProperties' => false,
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'results' => array(
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'id' => array( 'type' => 'integer' ),
'title' => array( 'type' => 'string' ),
'excerpt' => array( 'type' => 'string' ),
'url' => array( 'type' => 'string' ),
'modified' => array( 'type' => 'string' ),
),
),
),
'count' => array( 'type' => 'integer' ),
),
),
'permission_callback' => 'roa_can_read_content',
'execute_callback' => 'roa_execute_search_posts',
'show_in_rest' => false,
'meta' => array(
'annotations' => array(
'readonly' => true,
'destructive' => false,
'idempotent' => true,
),
),
)
);
}
Two deliberate choices. additionalProperties => false means an extra key from the model is a hard error rather than silently ignored — noisy, but it surfaces prompt drift immediately. And show_in_rest => false keeps the ability out of the wp-abilities/v1 endpoints entirely; we dispatch it ourselves, so there is no reason to widen the attack surface.
The other two abilities follow the same shape. roa/get-post takes id (integer, minimum 1) and returns id, title, content, url, categories. roa/list-categories takes an optional hide_empty boolean and a limit capped at 50, returning an array of { id, name, slug, count }.
Input schemas are the contract
Keep descriptions inside the schema, not just on the ability. The model reads parameters verbatim, and a one-line description on each property measurably reduces malformed calls. Use enum wherever the set of valid values is finite — it is the cheapest guardrail you will ever write.
Step 2 — Write permission callbacks that fail closed
A permission callback receives the same input array as the execute callback and must return true, false, or a WP_Error. Return a WP_Error when you want a specific status code and message; return false for a generic denial.
function roa_can_read_content( $input = null ) {
if ( ! is_user_logged_in() ) {
return new WP_Error(
'roa_not_authenticated',
__( 'You must be logged in to use this ability.', 'roa' ),
array( 'status' => 401 )
);
}
if ( ! current_user_can( 'read' ) ) {
return new WP_Error(
'roa_forbidden',
__( 'Your account cannot read content.', 'roa' ),
array( 'status' => 403 )
);
}
// Cheap per-user throttle: 30 ability calls per minute.
$key = 'roa_rl_' . get_current_user_id();
$count = (int) get_transient( $key );
if ( $count >= 30 ) {
return new WP_Error(
'roa_rate_limited',
__( 'Too many ability calls. Slow down.', 'roa' ),
array( 'status' => 429 )
);
}
set_transient( $key, $count + 1, MINUTE_IN_SECONDS );
return true;
}
Do not reuse roa_can_read_content for an ability that touches private data. roa/get-post needs its own callback that checks the specific post: if get_post_status( $id ) is not publish, require current_user_can( 'read_post', $id ). A generic read check would let a subscriber pull drafts by guessing IDs.
Step 3 — Execute callbacks with hard output caps
Every string that leaves the site should pass through a trimmer. Models have finite context, and an unbounded post body is both a cost problem and a prompt-injection vector — a 40,000-word page can contain instructions aimed at the agent.
function roa_trim( $text, $max = 280 ) {
$text = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( (string) $text ) ) );
if ( function_exists( 'mb_strimwidth' ) ) {
return mb_strimwidth( $text, 0, $max, '…', 'UTF-8' );
}
return strlen( $text ) > $max ? substr( $text, 0, $max ) . '…' : $text;
}
function roa_execute_search_posts( $input ) {
$query = new WP_Query(
array(
's' => $input['query'],
'post_type' => isset( $input['post_type'] ) ? $input['post_type'] : 'post',
'post_status' => 'publish',
'posts_per_page' => isset( $input['limit'] ) ? (int) $input['limit'] : 5,
'no_found_rows' => true,
'ignore_sticky_posts' => true,
)
);
$results = array();
foreach ( $query->posts as $post ) {
$results[] = array(
'id' => (int) $post->ID,
'title' => roa_trim( get_the_title( $post ), 160 ),
'excerpt' => roa_trim( get_the_excerpt( $post ), 280 ),
'url' => get_permalink( $post ),
'modified' => get_post_modified_time( 'c', true, $post ),
);
}
return array(
'results' => $results,
'count' => count( $results ),
);
}
For roa/get-post, cap the body at roughly 4,000 characters and strip shortcodes and block delimiters before trimming: roa_trim( strip_blocks( do_shortcode( $post->post_content ) ), 4000 ). The output must match output_schema; a mismatch is a bug you want to catch in staging, not in production.
Step 4 — Turn the ability registry into LLM tool definitions
The registry already holds everything the model needs. Convert each ability into a tool definition, but filter through an explicit allowlist — never publish the whole registry, because a future plugin may register something you did not intend to expose.
function roa_allowed_abilities() {
return array( 'roa/search-posts', 'roa/get-post', 'roa/list-categories' );
}
function roa_get_tool_definitions() {
$tools = array();
foreach ( wp_get_abilities() as $ability ) {
if ( ! in_array( $ability->get_name(), roa_allowed_abilities(), true ) ) {
continue;
}
$schema = $ability->get_input_schema();
// OpenAI requires an object, not an empty array, when there are no parameters.
if ( empty( $schema ) ) {
$schema = array( 'type' => 'object', 'properties' => new stdClass() );
}
$tools[] = array(
'type' => 'function',
'function' => array(
'name' => str_replace( '/', '__', $ability->get_name() ),
'description' => $ability->get_description(),
'parameters' => $schema,
),
);
}
return $tools;
}
Tool names must match [a-zA-Z0-9_-]{1,64}, which is why the slash becomes a double underscore. Reverse the transformation on the way back in — and only against the allowlist, never by trusting the string the model produced.
Step 5 — Dispatch tool calls server-side with an allowlist
This is the only place a model-supplied name reaches WordPress. Everything is checked here: allowlist, existence, permissions, execution, then a final size cap on the serialised payload.
function roa_dispatch_tool_call( $tool_name, $args ) {
$ability_name = str_replace( '__', '/', (string) $tool_name );
if ( ! in_array( $ability_name, roa_allowed_abilities(), true ) ) {
return new WP_Error( 'roa_unknown_tool', __( 'That tool is not available.', 'roa' ), array( 'status' => 400 ) );
}
$ability = wp_get_ability( $ability_name );
if ( ! $ability ) {
return new WP_Error( 'roa_missing_ability', __( 'Ability not registered.', 'roa' ), array( 'status' => 500 ) );
}
$args = is_array( $args ) ? $args : array();
$allowed = $ability->check_permissions( $args );
if ( is_wp_error( $allowed ) ) {
return $allowed;
}
if ( true !== $allowed ) {
return new WP_Error( 'roa_forbidden', __( 'Permission denied.', 'roa' ), array( 'status' => 403 ) );
}
$result = $ability->execute( $args );
if ( is_wp_error( $result ) ) {
return $result;
}
$json = wp_json_encode( $result );
if ( strlen( $json ) > 8000 ) {
return array(
'truncated' => true,
'note' => 'Result exceeded 8 KB and was shortened. Narrow the query.',
'preview' => substr( $json, 0, 8000 ),
);
}
return $result;
}
WP_Ability::execute() re-runs the permission check and validates input against the schema, so the explicit check_permissions() call above is for clearer error messages, not for safety. Keep it — a 403 that says “permission denied” is far easier to debug than a generic schema failure.
Step 6 — Run the tool loop behind a capability-checked REST route
The chat endpoint is a normal REST route with its own permission_callback. It loops: send messages plus tools, receive tool calls, dispatch each one, append the results, repeat. Cap the iterations — three or four is plenty, and an uncapped loop is a denial-of-service waiting to happen.
add_action( 'rest_api_init', 'roa_register_chat_route' );
function roa_register_chat_route() {
register_rest_route(
'roa/v1',
'/chat',
array(
'methods' => 'POST',
'callback' => 'roa_handle_chat',
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'args' => array(
'message' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
}
function roa_handle_chat( WP_REST_Request $request ) {
$messages = array(
array(
'role' => 'system',
'content' => 'You are a read-only assistant for this WordPress site. Use the provided tools. Never guess post IDs.',
),
array( 'role' => 'user', 'content' => $request->get_param( 'message' ) ),
);
$tools = roa_get_tool_definitions();
for ( $i = 0; $i < 4; $i++ ) {
$message = roa_call_llm( $messages, $tools );
if ( is_wp_error( $message ) ) {
return $message;
}
$messages[] = $message;
if ( empty( $message['tool_calls'] ) ) {
return rest_ensure_response( array( 'answer' => $message['content'] ) );
}
foreach ( $message['tool_calls'] as $call ) {
$args = json_decode( $call['function']['arguments'], true );
$result = roa_dispatch_tool_call( $call['function']['name'], $args );
$messages[] = array(
'role' => 'tool',
'tool_call_id' => $call['id'],
'content' => wp_json_encode( is_wp_error( $result ) ? array( 'error' => $result->get_error_message() ) : $result ),
);
}
}
return new WP_Error( 'roa_loop_limit', __( 'The assistant used too many steps.', 'roa' ), array( 'status' => 422 ) );
}
roa_call_llm() is a thin wp_remote_post() wrapper that sends model, messages and tools, reads the key from a ROA_API_KEY constant, and returns $body['choices'][0]['message']. Errors from the provider should come back as WP_Error so the route returns a clean JSON error instead of a 500 with a PHP notice.
On the front end, render the answer with textContent, not innerHTML. Model output is untrusted input:
async function askAgent( message, outputEl ) {
const reply = await wp.apiFetch( {
path: '/roa/v1/chat',
method: 'POST',
data: { message },
} );
outputEl.textContent = reply.answer;
}
Troubleshooting
_doing_it_wrongon activation. You registered oninit. Move categories towp_abilities_api_categories_initand abilities towp_abilities_api_init.- “Ability name must be namespaced”. The name needs exactly one slash, lowercase letters, digits and dashes only.
roa/SearchPostsfails;roa/search-postspasses. - Every call returns
ability_invalid_input. Almost alwaysadditionalProperties => falseplus a model that invented a key. Log the raw arguments before dispatch and tighten the property descriptions. - 401 on the REST route but the ability works in PHP. Cookie authentication needs a nonce. Send
X-WP-Noncefromwp_create_nonce( 'wp_rest' ). - Results are always truncated. The 8 KB cap is doing its job. Reduce
limit, shorten excerpts, or split the ability into a list call plus a detail call. - Nothing appears in
/wp-abilities/v1. Expected —show_in_restisfalse. Flip it only if you genuinely want the built-in run endpoint, and remember it enforces the same permission callback.
Next steps
- Add an audit log. Write one row per dispatch — user ID, ability name, argument hash, result size, duration — to a custom table. When an agent does something odd, this is the only record you will have.
- Gate on annotations. Read
meta.annotations.readonlyin your UI and auto-approve read-only calls while requiring a click for anything else. The same code then survives when you add write abilities later. - Try a write ability behind a confirmation step. Register
roa/create-draftwithreadonly => false, keep it out of the allowlist, and require an explicit second request from the user before dispatch. - Swap providers without touching the abilities. The tool definitions are plain JSON Schema, so moving from OpenAI to Anthropic or a local model is a change to
roa_call_llm()alone.
The rule that keeps this safe: the model proposes, WordPress disposes. Every name, argument and byte that reaches your database passes through a callback you wrote and an allowlist you control.
Leave a Reply