WP Tuts Hub

WordPress Tutorials, Themes and Plugins Hub
Advertisement

Browse

  • Home
  • Categories
    • WordPress Troubleshooting
    • WordPress Tips and Tricks
    • SSH (Secure Shell)
    • WordPress Insights
    • WordPress Common Facts
    • Securing WordPress
  • Series Tutorials
    • Advanced WordPress Topic
  • Privacy Policy
  • Terms of Use
  • Contact Me
  • About WP Tuts Hub
Home/Advanced WordPress Topic/Expose WordPress to an AI Agent Safely: Read-Only Abilities with the Abilities API and Permission Callbacks
Advanced WordPress Topic
October 25, 2026

Expose WordPress to an AI Agent Safely: Read-Only Abilities with the Abilities API and Permission Callbacks

Expose WordPress to an AI Agent Safely: Read-Only Abilities with the Abilities API and Permission Callbacks

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 against input_schema and returns a WP_Error on mismatch. A hallucinated post_type of revision never 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 => true in meta.annotations lets 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.php as 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_wrong on activation. You registered on init. Move categories to wp_abilities_api_categories_init and abilities to wp_abilities_api_init.
  • “Ability name must be namespaced”. The name needs exactly one slash, lowercase letters, digits and dashes only. roa/SearchPosts fails; roa/search-posts passes.
  • Every call returns ability_invalid_input. Almost always additionalProperties => false plus 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-Nonce from wp_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_rest is false. 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.readonly in 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-draft with readonly => 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.

Share this:

  • Share on X (Opens in new window) X
  • Share on Facebook (Opens in new window) Facebook
  • Share on LinkedIn (Opens in new window) LinkedIn
  • Share on WhatsApp (Opens in new window) WhatsApp
  • Share on Reddit (Opens in new window) Reddit
  • Email a link to a friend (Opens in new window) Email

Related

Previous Article Build a Provider-Agnostic AI Client for WordPress: Anthropic, OpenAI and Gemini Behind One wp_remote_post Wrapper

About the author

Md. Zubaer Ahammed
Md. Zubaer Ahammed

Md. Zubaer Ahammed is a senior full-stack developer building learning platforms and modern web systems with WordPress, Laravel, Python/Django, Vue, and React. More at https://zubaer.com/.

Related Posts

  • Build a Provider-Agnostic AI Client for WordPress: Anthropic, OpenAI and Gemini Behind One wp_remote_post Wrapper Build a Provider-Agnostic AI Client for WordPress: Anthropic, OpenAI and Gemini Behind One wp_remote_post Wrapper
  • Stop leaking OpenAI Keys in WordPress Stop Leaking OpenAI Keys in WordPress: Build a REST Proxy with Capability Checks, Rate Limits and Logging

Leave a Reply

Cancel reply

WP Tuts Hub

WordPress tutorials, themes, and plugins — written for builders.

  • Portfolio
  • Calorie Calculator Pro
  • Contact
  • About
  • Privacy
  • Terms

© 2026 WP Tuts Hub · Md. Zubaer Ahammed