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.
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.
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
- wp_insert_post() function reference, developer.wordpress.org
- PHP AI Client SDK repository, GitHub
- wp_kses_post() function reference, developer.wordpress.org