This project builds an ability that takes a post ID and a target keyword, pulls the post’s real content, and asks a model to analyze it for SEO quality against that keyword, then returns specific, actionable suggestions rather than a vague score.
Course 1 for wp_register_ability() and Course 5 for wp_ai_client_prompt(). You’ll
also want at least one real published post with a meaningful amount of body content to
test against.
Step 1: Register the ability
The input is a post ID and a target keyword. The output is a structured object: an overall assessment plus a list of concrete suggestions.
// File: seo-optimizer/seo-optimizer.php
add_action( 'wp_abilities_api_init', function () {
wp_register_ability(
'seo-optimizer/analyze-post',
array(
'label' => __( 'Analyze post for SEO', 'seo-optimizer' ),
'description' => __( 'Analyzes a published or draft post against a target keyword and returns specific SEO suggestions, covering title, headings, keyword density, and meta description.', 'seo-optimizer' ),
'category' => 'content',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'post_id' => array( 'type' => 'integer' ),
'target_keyword' => array( 'type' => 'string' ),
),
'required' => array( 'post_id', 'target_keyword' ),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'overall' => array( 'type' => 'string' ),
'suggestions' => array(
'type' => 'array',
'items' => array( 'type' => 'string' ),
),
),
),
'permission_callback' => function ( $input ) {
return current_user_can( 'edit_post', $input['post_id'] );
},
'execute_callback' => 'seo_optimizer_analyze_post',
'meta' => array(
'annotations' => array( 'readonly' => true ),
'mcp' => array( 'public' => true ),
),
)
);
} );
Note the permission_callback checks edit_post on the specific post ID, not a
blanket edit_posts, so a contributor can only analyze posts they’re actually allowed
to touch.
Step 2: The execute_callback, and the AI Client SDK call inside it
This is where the two directions meet. The ability is the agents-in surface, the
wp_ai_client_prompt() call inside it is the agents-out engine doing the real
analysis.
// File: seo-optimizer/seo-optimizer.php
function seo_optimizer_analyze_post( $input ) {
$post = get_post( $input['post_id'] );
if ( ! $post ) {
return new WP_Error( 'not_found', 'No post found with that ID.' );
}
$keyword = sanitize_text_field( $input['target_keyword'] );
$body = wp_strip_all_tags( $post->post_content );
$prompt = <<<PROMPT
Analyze this blog post for SEO against the target keyword "{$keyword}".
Title: {$post->post_title}
Content: {$body}
Return a brief overall assessment (one or two sentences), then a list of specific,
actionable suggestions covering: whether the keyword appears in the title, whether it
appears in the first paragraph, heading structure, keyword usage frequency (too little
or too much), and a suggested meta description under 155 characters.
PROMPT;
$response = wp_ai_client_prompt( $prompt )
->using_model_preference( 'anthropic/claude', 'openai/gpt', 'google/gemini' )
->generate_text();
if ( empty( $response ) ) {
return new WP_Error( 'empty_response', 'The AI provider returned no analysis.' );
}
return seo_optimizer_parse_response( $response );
}
function seo_optimizer_parse_response( $response ) {
// Simple line-based parsing: first non-empty line is the overall assessment,
// remaining lines starting with a dash or number become suggestions.
$lines = array_filter( array_map( 'trim', explode( "\n", $response ) ) );
$overall = '';
$suggestions = array();
foreach ( $lines as $line ) {
if ( preg_match( '/^[-*\d.]+\s*(.+)/', $line, $m ) ) {
$suggestions[] = $m[1];
} elseif ( empty( $overall ) ) {
$overall = $line;
}
}
return array(
'overall' => $overall,
'suggestions' => $suggestions,
);
}
using_model_preference() lists providers in priority order, if the first one is
unavailable or fails, the SDK falls back to the next. Course 5 covers this in depth,
here it just means one flaky API call doesn’t break the whole feature.
Step 3: Why this ability stays read-only
This ability never calls wp_update_post(). It reads a post and returns text. That’s
deliberate, and it’s why meta.annotations marks it readonly => true: an MCP client
can show this to a user as safe to run without a confirmation step, because nothing on
the site changes as a result. If you want the suggestions actually applied to the post,
that’s a separate, explicit ability, and a natural extension once you’ve built the
Bulk-Editing Assistant in Lesson 6.
Test it
Run the ability directly with WP-CLI against a real post ID on your site:
wp-env run cli wp eval '
$result = wp_get_ability( "seo-optimizer/analyze-post" )->execute( array(
"post_id" => 42,
"target_keyword" => "wordpress ai automation",
) );
print_r( $result );
'
You should see an overall string and a non-empty suggestions array reflecting the
actual content of post 42, not generic filler. If you’re running this through an MCP
client instead, connect and call analyze-post with the same arguments, the response
should come back structured the same way.
If post_content is empty or just a placeholder, the model has nothing real to
analyze and will produce generic, unhelpful suggestions that read plausibly but aren’t
grounded in anything. Add a minimum length check (a few hundred characters is
reasonable) before calling wp_ai_client_prompt(), and return a clear error instead of
spending an API call on a post that has no real content yet.
Recap
This ability pairs wp_register_ability() as the agent-facing surface with an
internal wp_ai_client_prompt() call that does the actual keyword analysis, then
parses the model’s response into a structured overall plus suggestions shape. It
stays strictly read-only, meta.annotations.readonly reflects that, making it safe for
an agent or a human to run freely without touching published content.
Resources & further reading
- wp_register_ability() function reference, developer.wordpress.org
- PHP AI Client SDK repository, GitHub
- sanitize_text_field() function reference, developer.wordpress.org