Editorial Automation

Project: Bulk-Editing Assistant

⏱ 16 min

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.

What you'll learn in this lesson
How to target a set of posts with WP_Query inside an ability
Filtering by category, date range, or status rather than hardcoding IDs.
How to build a dry-run/preview mode into an ability
Returning a before/after diff instead of writing changes, by default.
How a separate commit call applies previewed changes
Requiring the exact same filter and a token to actually write.
Why bulk operations need stronger safeguards than single-post ones
The blast radius of one mistake scales with how many posts match.
Prerequisites

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 );
}
Why two calls instead of one dry_run flag
1
A boolean can be flipped by mistake
One wrong argument in an agent-generated call and dry_run silently becomes false.
2
Two abilities means two deliberate calls
Commit literally cannot run without first calling preview and obtaining its token.
3
The token pins the exact content
What gets written is exactly what a human (or agent) already reviewed, not a fresh regeneration.

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.

Letting the preview transient live forever, or not at all

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

← Project: Meta Description and Alt-Text Generator Project: Content Moderation Tool →