WP Tuts Hub

WordPress Tutorials, Themes and Plugins Hub
Advertisement

Browse

  • Home
  • Categories
    • WordPress Troubleshooting
    • WordPress Tips and Tricks
    • SSH (Secure Shell)
    • WordPress Insights
    • WordPress Common Facts
    • Securing WordPress
  • Series Tutorials
    • Advanced WordPress Topic
  • Privacy Policy
  • Terms of Use
  • Contact Me
  • About WP Tuts Hub
Home/Advanced WordPress Topic/AI Drafts, Human Approval: Build a “Needs Review” Workflow for AI-Written WordPress Posts
Advanced WordPress Topic
October 28, 2026

AI Drafts, Human Approval: Build a “Needs Review” Workflow for AI-Written WordPress Posts

AI Drafts, Human Approval: Build a "Needs Review" Workflow for AI-Written WordPress Posts

Why a post status beats a spreadsheet

An AI model can produce a publishable-looking 900-word draft in four seconds. It cannot take responsibility for it. That gap between generation speed and editorial accountability is where most AI publishing setups quietly fall apart — usually into a shared spreadsheet, a Slack thread, and a note that says “check before publishing” which nobody checks.

WordPress already has a state machine for content. Adding ai_review to it means the draft is a first-class object: it appears in the posts list, it can be queried, it can be counted, it cannot leak to the front end, and the transition out of it can be gated on a capability check and recorded in post meta. That is a far better audit trail than a spreadsheet.

This tutorial builds the whole loop: the status registration, the admin list integration, the generator hand-off, a side-by-side diff metabox, a capability map, and an AJAX approve handler with nonce and current_user_can() checks. Everything below is plugin code — do not put it in a theme’s functions.php, because switching themes would strand every post sitting in ai_review.

Prerequisites

  • WordPress 6.4 or later (tested against 6.7). Custom post statuses have been stable for years, but wp_text_diff() and the REST status controller behave best on modern versions.
  • PHP 8.0+. The snippets use typed function signatures and return types.
  • A plugin or mu-plugin you control. A single file at wp-content/plugins/wptuts-ai-review/wptuts-ai-review.php is enough.
  • An AI generator that already produces text — any API, any language. This tutorial does not care which model you use; it cares where the output lands.
  • Comfort with hooks, nonces, and the difference between a primitive capability and a meta capability.
  • A staging site. You are about to add a post status that the block editor will see.

Step 1 — Register the ai_review status

Registration must happen on init, not admin_init. Cron jobs, WP-CLI commands, and REST requests all need to resolve the status, and none of them load the admin. If you register late, wp_insert_post() silently falls back to draft and you will spend an afternoon wondering why your generator’s output keeps landing in the wrong bucket.

<?php
/**
 * Plugin Name: WP Tuts Hub — AI Review Gate
 * Description: Adds an ai_review post status and a human approval step.
 */

add_action( 'init', 'wptuts_register_ai_review_status' );

function wptuts_register_ai_review_status() {
	register_post_status(
		'ai_review',
		array(
			'label'                     => _x( 'Needs Review', 'post status', 'wptuts' ),
			'public'                    => false,
			'internal'                  => false,
			'protected'                 => true,
			'exclude_from_search'       => true,
			'show_in_admin_all_list'    => true,
			'show_in_admin_status_list' => true,
			'date_floating'             => false,
			'label_count'               => _n_noop(
				'Needs Review <span class="count">(%s)</span>',
				'Needs Review <span class="count">(%s)</span>',
				'wptuts'
			),
		)
	);
}

Why these values matter:

  • public => false keeps unreviewed drafts off the front end. A request for the permalink returns a 404 rather than a half-finished article with a hallucinated statistic in paragraph three.
  • internal => false exposes the status at /wp/v2/statuses, which is what makes it appear in the block editor’s own status dropdown. If you would rather the approval metabox be the only route out of ai_review, flip this to true — the trade-off is that the status also drops out of some REST-driven admin UI.
  • protected => true marks it as an editorial state rather than a public one, so WordPress will not treat it as a valid target for anonymous queries.
  • show_in_admin_all_list and show_in_admin_status_list are what put the count next to “All” and the entry in the status filter. Without them the status exists but is invisible, which defeats the point.
  • label_count must use _n_noop(), not _n(). WordPress translates it lazily; calling _n() here translates too early and the count string breaks.

Step 2 — Surface the status in the posts list

WordPress builds the status filter dropdown from a hardcoded array of core statuses. Custom statuses do not appear automatically, so you inject the option with a small script on admin_footer-edit.php. Note the hyphen in the hook name — it is a dynamic hook, admin_footer-{hook_suffix}, and edit.php is the suffix for the posts list.

<?php
add_action( 'admin_footer-edit.php', 'wptuts_add_status_filter_option' );

function wptuts_add_status_filter_option() {
	global $post_type_object;

	if ( ! $post_type_object instanceof WP_Post_Type || 'post' !== $post_type_object->name ) {
		return;
	}

	if ( ! current_user_can( $post_type_object->cap->edit_others_posts ) ) {
		return;
	}

	$current = isset( $_GET['post_status'] ) ? sanitize_key( wp_unslash( $_GET['post_status'] ) ) : '';
	$label   = __( 'Needs Review', 'wptuts' );
	?>
	<script>
	( function () {
		var select = document.getElementById( 'post_status' );
		if ( ! select ) { return; }

		var option = document.createElement( 'option' );
		option.value = 'ai_review';
		option.textContent = <?php echo wp_json_encode( $label ); ?>;
		if ( 'ai_review' === <?php echo wp_json_encode( $current ); ?> ) {
			option.selected = true;
		}
		select.appendChild( option );
	}() );
	</script>
	<?php
}

The wp_json_encode() calls are not decoration. They are how you get a translated string into a JavaScript context without breaking on an apostrophe or a non-ASCII character. Never echo a raw PHP string into an inline script.

Because show_in_admin_all_list is true, the “All” count now includes pending AI drafts. That is deliberate: it makes the queue visible to whoever opens the posts screen, and it means the number goes down as the team works through it.

Step 3 — Send the generator’s output into ai_review

Your generator should have exactly one write path into WordPress. Keep it boring: sanitise, insert, store provenance, return the ID.

<?php
function wptuts_ai_store_draft( string $title, string $ai_body, string $source_notes, int $author_id ) : int {
	$post_id = wp_insert_post(
		array(
			'post_type'    => 'post',
			'post_status'  => 'ai_review',
			'post_title'   => sanitize_text_field( $title ),
			'post_content' => wp_kses_post( $ai_body ),
			'post_author'  => $author_id,
		),
		true
	);

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

	update_post_meta( $post_id, '_wptuts_ai_source',       wp_kses_post( $source_notes ) );
	update_post_meta( $post_id, '_wptuts_ai_model',        'gpt-4o-mini' );
	update_post_meta( $post_id, '_wptuts_ai_generated_at', current_time( 'mysql', true ) );

	return (int) $post_id;
}

The _wptuts_ai_source meta key is the load-bearing part. It holds the brief, the research notes, or the previous version of the article — whatever the human needs to compare the model’s output against. Without it, the “diff” in the next step has nothing to diff against and the approval becomes a rubber stamp.

Store the model identifier too. Six months from now, when someone asks why a batch of posts all cite the same non-existent study, “which model wrote this” is the first question you will want answered.

Step 4 — Build the side-by-side diff metabox

Do not hand-roll a diff. WordPress ships wp_text_diff(), the same function the revision screen uses. It produces a two-column table with insertions and deletions marked, and it escapes its own output, so you are not passing raw model text through a template unescaped.

<?php
add_action( 'add_meta_boxes_post', 'wptuts_add_review_metabox' );

function wptuts_add_review_metabox() {
	add_meta_box(
		'wptuts-ai-review',
		__( 'AI draft — human approval', 'wptuts' ),
		'wptuts_render_review_metabox',
		'post',
		'normal',
		'high'
	);
}

function wptuts_render_review_metabox( WP_Post $post ) {
	if ( 'ai_review' !== $post->post_status ) {
		echo '<p>' . esc_html__( 'This post is not awaiting AI review.', 'wptuts' ) . '</p>';
		return;
	}

	if ( ! current_user_can( 'approve_ai_draft', $post->ID ) ) {
		echo '<p>' . esc_html__( 'You do not have permission to approve AI drafts.', 'wptuts' ) . '</p>';
		return;
	}

	require_once ABSPATH . 'wp-admin/includes/revision.php';

	$source = (string) get_post_meta( $post->ID, '_wptuts_ai_source', true );
	$model  = (string) get_post_meta( $post->ID, '_wptuts_ai_model', true );

	wp_nonce_field( 'wptuts_ai_approve_' . $post->ID, 'wptuts_ai_nonce' );

	echo wp_text_diff(
		$source,
		$post->post_content,
		array(
			'title_left'  => __( 'Source notes', 'wptuts' ),
			'title_right' => sprintf( __( 'AI draft (%s)', 'wptuts' ), $model ),
		)
	);
	?>
	<p class="wptuts-approve-row" data-post-id="<?php echo esc_attr( $post->ID ); ?>">
		<button type="button" class="button button-primary" id="wptuts-approve">
			<?php esc_html_e( 'Approve and publish', 'wptuts' ); ?>
		</button>
		<span class="spinner"></span>
	</p>
	<?php
}

Two details worth flagging. First, the nonce action is scoped to the post ID — wptuts_ai_approve_42 is not valid for post 43, so a captured nonce cannot be replayed against a different draft. Second, wp_text_diff() lives in wp-admin/includes/revision.php, which is loaded on the post edit screen but not everywhere. The explicit require_once makes the function safe to call from any admin context.

If the source notes are empty, wp_text_diff() returns false and you get a blank metabox. Guard against that by falling back to a plain wpautop() render of the AI body, or by refusing to create ai_review posts without source meta in the first place.

Step 5 — Gate approval behind a capability

Do not check edit_posts here. Contributors can edit posts; they should not be able to wave their own AI drafts through to publication. Define a distinct capability and map it with map_meta_cap so it can be granted per-role later without touching the handler.

<?php
add_filter( 'map_meta_cap', 'wptuts_map_ai_approval_cap', 10, 4 );

function wptuts_map_ai_approval_cap( array $caps, string $cap, int $user_id, array $args ) : array {
	if ( 'approve_ai_draft' !== $cap ) {
		return $caps;
	}

	$post = get_post( isset( $args[0] ) ? absint( $args[0] ) : 0 );

	if ( ! $post || 'ai_review' !== $post->post_status ) {
		return array( 'do_not_allow' );
	}

	return array( 'edit_others_posts' );
}

Returning do_not_allow for anything that is not currently in ai_review is the important line. It means the capability is only meaningful while the post is in the review state — you cannot use it as a back door to edit published articles.

Step 6 — The AJAX approve handler

Order of checks matters more than the checks themselves. Verify the nonce, resolve the post, confirm the post is still in the expected state, then check capabilities. Doing the capability check first leaks whether a post ID exists to an unauthorised caller.

<?php
add_action( 'wp_ajax_wptuts_ai_approve', 'wptuts_ajax_approve_ai_draft' );

function wptuts_ajax_approve_ai_draft() {
	$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;

	if ( ! $post_id || ! check_ajax_referer( 'wptuts_ai_approve_' . $post_id, 'nonce', false ) ) {
		wp_send_json_error( array( 'message' => __( 'Security check failed. Reload the post and try again.', 'wptuts' ) ), 400 );
	}

	$post = get_post( $post_id );

	if ( ! $post || 'post' !== $post->post_type ) {
		wp_send_json_error( array( 'message' => __( 'Post not found.', 'wptuts' ) ), 404 );
	}

	if ( 'ai_review' !== $post->post_status ) {
		wp_send_json_error( array( 'message' => __( 'This draft is no longer awaiting review.', 'wptuts' ) ), 409 );
	}

	if ( ! current_user_can( 'approve_ai_draft', $post_id ) || ! current_user_can( 'publish_post', $post_id ) ) {
		wp_send_json_error( array( 'message' => __( 'You are not allowed to publish this draft.', 'wptuts' ) ), 403 );
	}

	update_post_meta( $post_id, '_wptuts_ai_approved_by', get_current_user_id() );
	update_post_meta( $post_id, '_wptuts_ai_approved_at', current_time( 'mysql', true ) );

	$updated = wp_update_post(
		array(
			'ID'          => $post_id,
			'post_status' => 'publish',
		),
		true
	);

	if ( is_wp_error( $updated ) ) {
		wp_send_json_error( array( 'message' => $updated->get_error_message() ), 500 );
	}

	wp_send_json_success(
		array(
			'message'  => __( 'Approved and published.', 'wptuts' ),
			'edit_url' => get_edit_post_link( $post_id, 'raw' ),
			'view_url' => get_permalink( $post_id ),
		)
	);
}

Write the approval meta before the status change, not after. If wp_update_post() fails halfway through, you want the record of intent to exist so you can reconcile it. Writing after means a failed publish leaves no trace that anyone tried.

check_ajax_referer() with the third argument set to false returns false instead of calling die(). That lets you send a structured JSON error with a 400 status, which the front-end script can display properly.

Step 7 — Wire the button

Use fetch() with FormData. No innerHTML, no string concatenation into the DOM — the response is JSON, and the only thing you do with it is redirect.

( function () {
	var button = document.getElementById( 'wptuts-approve' );
	if ( ! button ) { return; }

	button.addEventListener( 'click', function () {
		var row     = button.closest( '.wptuts-approve-row' );
		var nonce   = document.getElementById( 'wptuts_ai_nonce' );
		var spinner = row.querySelector( '.spinner' );

		button.disabled = true;
		spinner.classList.add( 'is-active' );

		var body = new FormData();
		body.append( 'action', 'wptuts_ai_approve' );
		body.append( 'post_id', row.dataset.postId );
		body.append( 'nonce', nonce.value );

		fetch( window.ajaxurl, { method: 'POST', credentials: 'same-origin', body: body } )
			.then( function ( response ) { return response.json(); } )
			.then( function ( result ) {
				if ( ! result.success ) { throw new Error( result.data.message ); }
				window.location.assign( result.data.edit_url );
			} )
			.catch( function ( error ) {
				window.alert( error.message );
				button.disabled = false;
				spinner.classList.remove( 'is-active' );
			} );
	} );
}() );

Enqueue this from admin_enqueue_scripts, scoped to the post edit screen, rather than printing it inline. window.ajaxurl is already defined in the admin, so no localisation is needed for the endpoint.

Troubleshooting

Drafts keep saving as “Draft”

The status is not registered at the moment wp_insert_post() runs. Check that register_post_status() is on init with a priority low enough to beat your generator’s hook, and that the plugin is actually active on the environment where cron runs.

The status filter option never appears

admin_footer-edit.php only fires on the posts list screen. If you are testing on a custom post type list, the suffix is edit.php?post_type=book and the hook is still admin_footer-edit.php — but the $post_type_object->name guard in the snippet will bail. Adjust the guard, not the hook.

“Security check failed” after a long review session

Nonces expire after 24 hours by default, and a draft that sat in review over a weekend will fail. That is correct behaviour — the fix is to reload the edit screen, not to extend the nonce lifetime.

Fatal error: undefined function wp_text_diff()

You are calling it outside the post edit screen. Add the require_once ABSPATH . 'wp-admin/includes/revision.php'; line shown in step 4.

The block editor shows “Needs Review” in its own dropdown

That is internal => false doing its job — the status is exposed at /wp/v2/statuses. Anyone who can select it can also publish, so it is not a security hole, but if you want the metabox to be the only exit, set internal => true.

Approved posts still 404 on the front end

Object cache. Call clean_post_cache( $post_id ) after the status change if you are running a persistent cache, and check that no pre_get_posts filter is hardcoding a list of allowed statuses.

Next steps

  • Notify on entry. Hook transition_post_status and send a Slack or email message when a post lands in ai_review, so the queue does not silently grow.
  • Bulk approve. Add a bulk_actions-edit-post entry that runs the same capability and nonce checks per post. Resist the temptation to skip them because “it is an admin screen”.
  • Detect post-approval edits. Store a hash of post_content in _wptuts_ai_approved_hash and compare it on save_post. If the body changed after approval, flag it — that is how you catch someone quietly rewriting an approved article.
  • Show the approver in the list. A custom column reading _wptuts_ai_approved_by turns the posts screen into an audit log.
  • Register a block pattern for the review layout so the diff metabox and the approval button render consistently across custom post types, rather than duplicating the markup.

The rule that makes this workflow worth building: no post leaves ai_review without a user ID attached to it. Everything else — the diff, the capability, the nonce — exists to make that one guarantee true.

Share this:

  • Share on X (Opens in new window) X
  • Share on Facebook (Opens in new window) Facebook
  • Share on LinkedIn (Opens in new window) LinkedIn
  • Share on WhatsApp (Opens in new window) WhatsApp
  • Share on Reddit (Opens in new window) Reddit
  • Email a link to a friend (Opens in new window) Email

Related

Previous Article Bulk-Generate WooCommerce Product Descriptions with the OpenAI Batch API and a WP-CLI Review Queue

About the author

Md. Zubaer Ahammed
Md. Zubaer Ahammed

Md. Zubaer Ahammed is a senior full-stack developer building learning platforms and modern web systems with WordPress, Laravel, Python/Django, Vue, and React. More at https://zubaer.com/.

Related Posts

  • Bulk-Generate WooCommerce Product Descriptions with the OpenAI Batch API and a WP-CLI Review Queue Bulk-Generate WooCommerce Product Descriptions with the OpenAI Batch API and a WP-CLI Review Queue
  • Expose WordPress to an AI Agent Safely: Read-Only Abilities with the Abilities API and Permission Callbacks Expose WordPress to an AI Agent Safely: Read-Only Abilities with the Abilities API and Permission Callbacks
WP Tuts Hub

WordPress tutorials, themes, and plugins — written for builders.

  • Portfolio
  • Calorie Calculator Pro
  • Contact
  • About
  • Privacy
  • Terms

© 2026 WP Tuts Hub · Md. Zubaer Ahammed