Content Projects

Project: Automated Blog Post Generator

⏱ 15 min

This project builds an ability that takes a topic and an optional outline, generates a full draft post with the AI Client SDK, and creates it in WordPress with wp_insert_post(), set to draft status every single time, no exceptions.

What you'll learn in this lesson
How to turn a topic into a structured generation prompt
Including an optional outline so the model has real structure to follow.
How to combine wp_ai_client_prompt() with wp_insert_post()
Generating content, then persisting it as a real WordPress post.
Why draft status is hardcoded, not a parameter
Making it structurally impossible for this ability to publish anything.
How to return a useful result an agent can act on
An edit link and post ID, not just a success message.
Prerequisites

Course 1 for ability registration and Course 5 for wp_ai_client_prompt(). A user account with edit_posts capability to test with, generated drafts will be attributed to whichever user triggers the ability.

Step 1: Register the ability

Input is a topic, an optional outline, and an optional category. Output is the new post’s ID and edit URL, never a “published” flag, because this ability never publishes anything.

// File: blog-generator/blog-generator.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'blog-generator/generate-draft',
		array(
			'label'               => __( 'Generate blog post draft', 'blog-generator' ),
			'description'         => __( 'Generates a full draft blog post from a topic and optional outline using AI, and creates it in WordPress as a draft. Never publishes.', 'blog-generator' ),
			'category'            => 'content',
			'input_schema'        => array(
				'type'       => 'object',
				'properties' => array(
					'topic'    => array( 'type' => 'string' ),
					'outline'  => array( 'type' => 'string' ),
					'category' => array( 'type' => 'string' ),
				),
				'required'   => array( 'topic' ),
			),
			'output_schema'       => array(
				'type'       => 'object',
				'properties' => array(
					'post_id'  => array( 'type' => 'integer' ),
					'edit_url' => array( 'type' => 'string' ),
				),
			),
			'permission_callback' => function () {
				return current_user_can( 'edit_posts' );
			},
			'execute_callback'    => 'blog_generator_create_draft',
			'meta' => array(
				'annotations' => array( 'destructive' => false, 'idempotent' => false ),
				'mcp'         => array( 'public' => true ),
			),
		)
	);
} );

idempotent is false because calling this twice with the same topic creates two separate drafts, it’s not marked destructive since it only ever adds content, never removes or overwrites anything.

Step 2: Generate the draft with the AI Client SDK

// File: blog-generator/blog-generator.php
function blog_generator_create_draft( $input ) {
	$topic   = sanitize_text_field( $input['topic'] );
	$outline = isset( $input['outline'] ) ? sanitize_textarea_field( $input['outline'] ) : '';

	$prompt = "Write a complete blog post about: {$topic}\n\n";
	if ( $outline ) {
		$prompt .= "Follow this outline:\n{$outline}\n\n";
	}
	$prompt .= "Return the post title on the first line prefixed with 'Title: ', then the full post body in Markdown after a blank line.";

	$response = wp_ai_client_prompt( $prompt )
		->using_model_preference( 'anthropic/claude', 'openai/gpt' )
		->generate_text();

	if ( empty( $response ) ) {
		return new WP_Error( 'empty_response', 'The AI provider returned no content, no draft was created.' );
	}

	list( $title, $body ) = blog_generator_split_title_body( $response );

	$post_args = array(
		'post_title'   => sanitize_text_field( $title ),
		'post_content' => wp_kses_post( $body ),
		'post_status'  => 'draft', // Hardcoded. Never accept this from input.
		'post_type'    => 'post',
	);

	if ( ! empty( $input['category'] ) ) {
		$term = get_term_by( 'name', sanitize_text_field( $input['category'] ), 'category' );
		if ( $term ) {
			$post_args['post_category'] = array( $term->term_id );
		}
	}

	$post_id = wp_insert_post( $post_args, true );

	if ( is_wp_error( $post_id ) ) {
		return $post_id;
	}

	return array(
		'post_id'  => $post_id,
		'edit_url' => get_edit_post_link( $post_id, 'raw' ),
	);
}

function blog_generator_split_title_body( $response ) {
	if ( preg_match( '/^Title:\s*(.+)$/mi', $response, $m ) ) {
		$title = trim( $m[1] );
		$body  = trim( str_replace( $m[0], '', $response ) );
	} else {
		$title = 'Untitled draft';
		$body  = $response;
	}
	return array( $title, $body );
}

post_status is set to the literal string 'draft' in the code, it is never read from $input. That’s not a style choice, it’s the entire safety mechanism for this ability.

Step 3: Why draft-only isn’t a setting

An easy but risky version of this project would add a publish_immediately boolean to input_schema and let the caller decide. Don’t. An AI model deciding, on its own judgment, that generated content is ready for the public internet is a different and much larger risk than an AI model drafting content for a human to review. Keeping post_status hardcoded means there is no input combination, no prompt injection, and no agent misjudgment that can result in this ability publishing anything. Publishing stays a deliberate, separate, human action taken in wp-admin.

Test it

Call the ability with a topic and confirm a real draft appears:

wp-env run cli wp eval '
$result = wp_get_ability( "blog-generator/generate-draft" )->execute( array(
	"topic"   => "Why WordPress site owners should learn the basics of AI agents",
	"outline" => "1. What an AI agent is\n2. Why WordPress specifically\n3. First steps",
) );
print_r( $result );
'
wp-env run cli wp post list --post_status=draft --posts_per_page=1 --format=table

Confirm the new post’s status is draft in the second command’s output, and open the edit_url from the first to read the generated content.

Not checking for an empty AI response before inserting

If the AI provider call fails silently or returns an empty string, don’t let wp_insert_post() run anyway. It’s easy to end up with a titled, empty draft cluttering the post list. Always check the response is non-empty and return a WP_Error before reaching wp_insert_post(), as shown above, so a failed generation never creates a half-formed post.

Recap

This ability combines a wp_ai_client_prompt() call that drafts the post with wp_insert_post() that persists it, with post_status hardcoded to draft so publishing always stays a separate human decision. The output returns a post ID and edit link so whoever (or whatever) triggered generation can immediately go review the result.

Resources & further reading

← Project: SEO Content Optimizer Project: Site-Content Chatbot →