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.
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.
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
- wp_register_ability() function reference, developer.wordpress.org
- Abilities API reference, developer.wordpress.org
- WordPress AI Foundations: Understanding Abilities, this track, Course 1