This project builds an ability that applies an AI-driven transformation, for example a
tone rewrite, across every post matching a WP_Query filter. Because a mistake here
touches many posts at once instead of one, the ability defaults to a dry-run preview
and requires an explicit second call to actually commit anything.
Courses 1 and 5. Ideally a staging or local environment with disposable test content, this project is explicitly the one lesson in this course where a bug could visibly affect many posts at once, so test somewhere it’s safe to.
Step 1: Register two abilities, preview and commit
Splitting this into two abilities, rather than one with a dry_run boolean, makes it
structurally harder to skip the preview step by accident.
// File: bulk-editor/bulk-editor.php
add_action( 'wp_abilities_api_init', function () {
$shared_input_schema = array(
'type' => 'object',
'properties' => array(
'category' => array( 'type' => 'string' ),
'instruction' => array( 'type' => 'string' ),
'preview_token' => array( 'type' => 'string' ),
),
'required' => array( 'category', 'instruction' ),
);
wp_register_ability( 'bulk-editor/preview', array(
'label' => __( 'Preview bulk edit', 'bulk-editor' ),
'description' => __( 'Shows a before/after preview of an AI rewrite across all published posts in a category. Makes no changes.', 'bulk-editor' ),
'category' => 'content',
'input_schema' => $shared_input_schema,
'output_schema' => array(
'type' => 'object',
'properties' => array(
'preview_token' => array( 'type' => 'string' ),
'changes' => array( 'type' => 'array' ),
),
),
'permission_callback' => function () {
return current_user_can( 'edit_others_posts' );
},
'execute_callback' => 'bulk_editor_preview',
'meta' => array( 'annotations' => array( 'readonly' => true ), 'mcp' => array( 'public' => true ) ),
) );
wp_register_ability( 'bulk-editor/commit', array(
'label' => __( 'Commit bulk edit', 'bulk-editor' ),
'description' => __( 'Applies a previously previewed bulk AI rewrite. Requires the exact preview_token from bulk-editor/preview, refuses to run otherwise.', 'bulk-editor' ),
'category' => 'content',
'input_schema' => $shared_input_schema,
'output_schema' => array(
'type' => 'object',
'properties' => array( 'updated' => array( 'type' => 'integer' ) ),
),
'permission_callback' => function () {
return current_user_can( 'edit_others_posts' );
},
'execute_callback' => 'bulk_editor_commit',
'meta' => array( 'annotations' => array( 'destructive' => true, 'idempotent' => false ), 'mcp' => array( 'public' => true ) ),
) );
} );
Notice bulk-editor/commit is marked destructive => true in its annotations, unlike
every read-only project earlier in this course, so an MCP client can flag it for
confirmation before an agent runs it.
Step 2: Find matching posts and preview the rewrite
// File: bulk-editor/bulk-editor.php
function bulk_editor_find_posts( $category ) {
return get_posts( array(
'post_status' => 'publish',
'posts_per_page' => -1,
'category_name' => sanitize_text_field( $category ),
) );
}
function bulk_editor_preview( $input ) {
$posts = bulk_editor_find_posts( $input['category'] );
$instruction = sanitize_text_field( $input['instruction'] );
$changes = array();
foreach ( $posts as $post ) {
$rewritten = wp_ai_client_prompt(
"{$instruction}\n\nRewrite this post content accordingly, keep the meaning intact, return only the rewritten content:\n\n{$post->post_content}"
)->generate_text();
if ( empty( $rewritten ) ) {
continue; // Skip posts where generation failed, don't preview a blank change.
}
$changes[] = array(
'post_id' => $post->ID,
'title' => $post->post_title,
'before' => wp_trim_words( wp_strip_all_tags( $post->post_content ), 40 ),
'after' => wp_trim_words( wp_strip_all_tags( $rewritten ), 40 ),
'full_new' => $rewritten,
);
}
$token = wp_generate_password( 20, false );
set_transient( 'bulk_editor_preview_' . $token, array(
'category' => $input['category'],
'instruction' => $instruction,
'changes' => $changes,
), HOUR_IN_SECONDS );
return array( 'preview_token' => $token, 'changes' => $changes );
}
The full rewritten content is stored in a transient behind the token, not regenerated at commit time. That guarantees what gets committed is exactly what was previewed, an identical AI call a moment later isn’t guaranteed to return the same text twice.
Step 3: Commit, only with a valid, matching token
// File: bulk-editor/bulk-editor.php
function bulk_editor_commit( $input ) {
if ( empty( $input['preview_token'] ) ) {
return new WP_Error( 'missing_token', 'A preview_token from bulk-editor/preview is required before committing.' );
}
$cached = get_transient( 'bulk_editor_preview_' . $input['preview_token'] );
if ( ! $cached ) {
return new WP_Error( 'expired_or_invalid', 'This preview has expired or was never generated. Run bulk-editor/preview again.' );
}
if ( $cached['category'] !== $input['category'] || $cached['instruction'] !== sanitize_text_field( $input['instruction'] ) ) {
return new WP_Error( 'mismatch', 'The category and instruction must match the previewed request exactly.' );
}
$updated = 0;
foreach ( $cached['changes'] as $change ) {
$result = wp_update_post( array(
'ID' => $change['post_id'],
'post_content' => wp_kses_post( $change['full_new'] ),
), true );
if ( ! is_wp_error( $result ) ) {
$updated++;
}
}
delete_transient( 'bulk_editor_preview_' . $input['preview_token'] );
return array( 'updated' => $updated );
}
Test it
Preview first, inspect the changes, then commit using the returned token:
wp-env run cli wp eval '
$preview = wp_get_ability( "bulk-editor/preview" )->execute( array(
"category" => "news",
"instruction" => "Rewrite in a more conversational, less formal tone.",
) );
print_r( $preview );
'
Review the before/after excerpts in the output, then commit with the exact same
category and instruction, plus the preview_token from the response:
wp-env run cli wp eval '
$result = wp_get_ability( "bulk-editor/commit" )->execute( array(
"category" => "news",
"instruction" => "Rewrite in a more conversational, less formal tone.",
"preview_token" => "PASTE_TOKEN_HERE",
) );
print_r( $result );
'
Confirm updated matches the number of posts you expected to change, then spot-check
one post’s content in wp-admin.
The example above expires previews after an hour with HOUR_IN_SECONDS. Too short and
a legitimate reviewer won’t have time to read the diff before committing, too long and
stale previews (against content that’s since changed again) can get committed against
a post that’s moved on. Pick an expiry that matches your actual editorial review time,
and always delete the transient after a successful commit so tokens can’t be reused.
Recap
Bulk edits get a two-step ability pair instead of a single toggleable call: preview
generates and caches every proposed change behind a token without writing anything,
commit requires that exact token and re-validates the request before calling
wp_update_post(). Marking commit as destructive in its meta.annotations lets
any MCP client add its own confirmation step on top of this course’s own preview
safeguard.
Resources & further reading
- WP_Query reference, developer.wordpress.org
- wp_update_post() function reference, developer.wordpress.org
- Transients API, developer.wordpress.org