Reuse

Storing Prompt Templates as Abilities

⏱ 16 min

Lesson 7 moved a prompt template out of hardcoded PHP and into wp_options, editable without a deploy. This lesson goes one step further: wrapping that template, and the whole prompt-building call around it, in a registered ability. Once a prompt template is an ability, it has one execution path, one input schema, and one permission check, whether it’s triggered by your own admin-side PHP or by an external AI agent connected through MCP. This is the natural place this course ends, since it’s where the Abilities API from Course 1 and everything this course has covered about prompt engineering meet in one piece of code.

What you'll learn in this lesson
Why wrap a prompt template in an ability
One execution path for humans and agents, with input_schema as the contract.
A real registration
Combining the stored template, a system instruction, and as_json_response() inside one execute_callback.
Calling it two ways
Directly via wp_get_ability()->execute(), and indirectly through an AI agent.
Keeping schema and template in sync
Why a schema change that outpaces the template is a silent bug.
Prerequisites

Course 1’s wp_register_ability() fundamentals, Lesson 7’s stored template pattern, and Lesson 3’s as_json_response() schema pattern. This lesson combines all three.

Step 1: Why an ability, and not just a function

A plain PHP function that builds a prompt and calls generate_text() works fine for your own code, but it has no input_schema an AI agent can read to know what arguments it takes, no permission_callback an MCP client’s request is checked against, and no consistent, discoverable name. Registering it as an ability gives it all three, plus one significant benefit: the same execute_callback now runs identically whether a WordPress admin screen calls it directly or a connected AI agent calls it through MCP, no second code path to keep in sync.

Step 2: Registering the ability

This wraps Lesson 7’s stored template, a system instruction, and Lesson 3’s schema-based structured output into one ability that generates a meta description for a given post:

// File: my-plugin/my-plugin.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'my-plugin/generate-meta-description',
		array(
			'label'       => __( 'Generate SEO meta description', 'my-plugin' ),
			'description' => __(
				'Generates a single SEO meta description for an existing WordPress ' .
				'post, 150 to 155 characters, active voice, no clickbait. Requires ' .
				'the numeric post ID of an already-published post.',
				'my-plugin'
			),
			'input_schema' => array(
				'type'       => 'object',
				'properties' => array(
					'post_id' => array(
						'type'        => 'integer',
						'description' => 'The numeric ID of the post to generate a meta description for.',
					),
				),
				'required' => array( 'post_id' ),
			),
			'output_schema' => array(
				'type'       => 'object',
				'properties' => array(
					'meta_description' => array( 'type' => 'string' ),
				),
				'required' => array( 'meta_description' ),
			),
			'permission_callback' => function ( $input ) {
				return current_user_can( 'edit_post', $input['post_id'] ?? 0 );
			},
			'execute_callback' => function ( $input ) {
				$post = get_post( $input['post_id'] );

				if ( ! $post ) {
					return new WP_Error( 'not_found', 'No post found with that ID.' );
				}

				$stored = get_option( 'my_plugin_support_reply_template' );
				// Reuses the same versioned-option pattern from Lesson 7, applied to
				// a different template key for this specific feature.
				$system_instruction = get_option(
					'my_plugin_meta_description_template',
					'Write a single SEO meta description, 150 to 155 characters, ' .
					'active voice, no clickbait, no quotation marks around the result.'
				);

				$raw = wp_ai_client_prompt()
					->with_text(
						'Post title: ' . $post->post_title . '. Content summary: ' .
						wp_trim_words( wp_strip_all_tags( $post->post_content ), 60 )
					)
					->using_system_instruction( $system_instruction )
					->as_json_response( array(
						'type'       => 'object',
						'properties' => array(
							'meta_description' => array( 'type' => 'string' ),
						),
						'required'   => array( 'meta_description' ),
					) )
					->using_temperature( 0.3 )
					->generate_text();

				$data = json_decode( $raw, true );

				if ( empty( $data['meta_description'] ) ) {
					return new WP_Error( 'invalid_response', 'The model did not return a usable meta description.' );
				}

				return array( 'meta_description' => $data['meta_description'] );
			},
			'meta' => array(
				'annotations' => array( 'readonly' => true ),
				'mcp'         => array( 'public' => true ),
			),
		)
	);
} );

readonly => true reflects that this ability only reads post data and calls out to an AI model, it never writes anything back to the site itself, that choice is deliberate and covered more in Step 4.

Step 3: Calling it two ways

The same ability now serves a bulk-edit screen in your own admin UI:

// File: my-plugin/class-bulk-edit-screen.php
$ability = wp_get_ability( 'my-plugin/generate-meta-description' );
$result  = $ability->execute( array( 'post_id' => $post_id ) );

if ( ! is_wp_error( $result ) ) {
	update_post_meta( $post_id, '_meta_description', $result['meta_description'] );
}

and an AI agent connected through MCP, discovering the ability from its description and calling it with a post_id it already has from an earlier step in the same conversation, no separate integration code needed on your side at all, the permission_callback and execute_callback are exactly the same either way.

Step 4: Keeping the schema and the template in sync

Because the prompt wording lives in an option, separate from the ability’s registration code, it’s possible to change the template in a way the input_schema or output_schema no longer accurately describes, adding a second output field to the template’s instructions without adding it to output_schema, for instance. Treat a prompt template change as a change to the ability’s contract, and update the schema in the same commit, or the same option update, that changes what the template asks the model to return.

Public MCP exposure raises the stakes on permission_callback

Setting meta.mcp.public to true means any connected AI agent, not just your own admin-side code, can call this ability if it can satisfy permission_callback. A current_user_can( 'edit_post', $input['post_id'] ) check that was perfectly adequate for an internal admin screen needs the same scrutiny you’d give any other externally-reachable endpoint once it’s reachable this way, since the caller is no longer only code you wrote yourself.

Test it: execute the ability directly

wp-env run cli wp eval '
$ability = wp_get_ability( "my-plugin/generate-meta-description" );
$result  = $ability->execute( array( "post_id" => 1 ) );
var_dump( $result );
'

You should see either an array with a meta_description key holding a real string, or a WP_Error if post 1 doesn’t exist or the model’s response failed validation, exactly the same two outcomes an AI agent calling this ability through MCP would see.

Recap

Wrapping a well-crafted prompt template in a registered ability gives it one execution path, one input schema, and one permission check, shared identically by your own PHP and by any connected AI agent. Combine the stored-template pattern from Lesson 7, the system instruction discipline from Lesson 1, and the structured JSON output pattern from Lesson 3 inside a single execute_callback, and treat a change to the prompt template as a change to the ability’s contract, keeping input_schema and output_schema accurate to what the template actually produces.

Resources & further reading

← Storing Reusable Prompt Templates as WordPress Options Finish ✓