Content Projects

Project: SEO Content Optimizer

⏱ 15 min

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.

What you'll learn in this lesson
How to build an analysis-only ability
One that reads content and returns suggestions without changing anything on the site.
How to combine post data and a target keyword into one prompt
Structuring input so the model has everything it needs and nothing it doesn't.
How to shape output_schema for structured suggestions
Getting back something a caller can render or act on, not a wall of prose.
Why this ability is read-only by design
And what that means for its meta.annotations.
Prerequisites

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.

Feeding the model an empty or near-empty post

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

← How These Projects Fit Together Project: Automated Blog Post Generator →