Every ability built so far in this course, whether it’s Lesson 2’s WPML translation, Lesson 3’s
Polylang equivalent, or Lesson 6’s product catalog batch, ends the same way: post_status => 'draft'. That’s the same rule Course 6’s original translation project established, and it’s not
a formality. This lesson turns “it’s a draft” into an actual reviewed workflow: a queue a
bilingual reviewer can see, and an explicit approval ability that’s the only thing allowed to
move a translation from draft to published.
Lesson 2 or Lesson 3 (or Lesson 6’s batch ability), this lesson wraps a review queue and approval gate around whichever of those you’ve already built, it doesn’t introduce a new translation mechanism. Course 25’s human-in-the-loop editing lesson for the underlying pattern this lesson applies to translation specifically.
Step 1: Draft status is necessary, but it isn’t sufficient
Every ability in this course creates translations as drafts, and that’s the right default,
exactly the same reasoning Course 6’s project and every project since have followed: machine
translation can mistranslate idioms, get formal or informal register wrong, or mishandle a term
that shouldn’t have been translated at all. But draft on its own is just a status, nothing stops
a well-meaning but different automation, a cron job, a bulk “publish all drafts in this category”
script, from moving it to publish without anyone bilingual ever reading it. A real review gate
needs a queue someone actually looks at, and an approval step that’s the only path to publish.
It’s tempting, once a translation pipeline is running reliably, to add a scheduled task that publishes translated drafts automatically after some number of days if nobody’s touched them. Don’t. That silently removes the one safeguard this entire course has built around every ability, an unreviewed AI translation reaching a live, public, non-source-language page. If a reviewer backlog is the real problem, solve that by adding reviewers or narrowing what gets translated, not by removing the review step.
Step 2: Build the pending-translations queue
This ability is deliberately read-only, it surfaces what needs review, it never changes anything.
// File: translation-review/translation-review.php
add_action( 'wp_abilities_api_init', function () {
wp_register_ability(
'translation-review/list-pending',
array(
'label' => __( 'List pending translations', 'translation-review' ),
'description' => __( 'Lists draft posts and products created by this course\'s translation abilities, awaiting bilingual reviewer approval. Changes nothing.', 'translation-review' ),
'category' => 'content',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'post_type' => array( 'type' => 'string', 'default' => 'post' ),
),
),
'output_schema' => array(
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'post_id' => array( 'type' => 'integer' ),
'title' => array( 'type' => 'string' ),
'language' => array( 'type' => 'string' ),
'edit_url' => array( 'type' => 'string' ),
),
),
),
'permission_callback' => function () {
return current_user_can( 'edit_others_posts' );
},
'execute_callback' => 'translation_review_list_pending',
'meta' => array(
'annotations' => array( 'readonly' => true ),
'mcp' => array( 'public' => true ),
),
)
);
} );
function translation_review_list_pending( $input ) {
$post_type = ! empty( $input['post_type'] ) ? sanitize_key( $input['post_type'] ) : 'post';
$drafts = get_posts( array(
'post_type' => $post_type,
'post_status' => 'draft',
'posts_per_page' => -1,
) );
$pending = array();
foreach ( $drafts as $draft ) {
$language = translation_review_get_language( $draft->ID );
if ( ! $language ) {
continue; // Not a translation this queue tracks, an ordinary unrelated draft.
}
$pending[] = array(
'post_id' => $draft->ID,
'title' => $draft->post_title,
'language' => $language,
'edit_url' => get_edit_post_link( $draft->ID, 'raw' ),
);
}
return $pending;
}
function translation_review_get_language( $post_id ) {
if ( function_exists( 'apply_filters' ) && has_filter( 'wpml_post_language_details' ) ) {
$details = apply_filters( 'wpml_post_language_details', null, $post_id );
return $details['language_code'] ?? null;
}
if ( function_exists( 'pll_get_post_language' ) ) {
return pll_get_post_language( $post_id );
}
return get_post_meta( $post_id, '_translation_language', true ) ?: null; // Course 6's fallback.
}
Step 3: Build the approval ability, the only path to publish
The approval ability requires the caller to explicitly confirm a review happened, not just call an endpoint that happens to publish. A boolean the caller has to set deliberately makes it harder to trigger by accident, the same reasoning Course 6’s bulk-editing project applied to its preview/commit split.
// File: translation-review/translation-review.php
add_action( 'wp_abilities_api_init', function () {
wp_register_ability(
'translation-review/approve',
array(
'label' => __( 'Approve a reviewed translation', 'translation-review' ),
'description' => __( 'Publishes a translated draft. Requires reviewer_confirmed to be explicitly true, refuses to publish otherwise. This is the only ability in this course that publishes a translation.', 'translation-review' ),
'category' => 'content',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'post_id' => array( 'type' => 'integer' ),
'reviewer_confirmed' => array( 'type' => 'boolean' ),
),
'required' => array( 'post_id', 'reviewer_confirmed' ),
),
'output_schema' => array(
'type' => 'object',
'properties' => array( 'published' => array( 'type' => 'boolean' ) ),
),
'permission_callback' => function ( $input ) {
return current_user_can( 'publish_posts' ) && current_user_can( 'edit_post', $input['post_id'] );
},
'execute_callback' => 'translation_review_approve',
'meta' => array(
'annotations' => array( 'destructive' => true, 'idempotent' => true ),
'mcp' => array( 'public' => true ),
),
)
);
} );
function translation_review_approve( $input ) {
if ( true !== $input['reviewer_confirmed'] ) {
return new WP_Error( 'not_confirmed', 'reviewer_confirmed must be explicitly true. This ability never publishes without it.' );
}
$post = get_post( $input['post_id'] );
if ( ! $post || 'draft' !== $post->post_status ) {
return new WP_Error( 'not_pending', 'This post is not a pending draft translation.' );
}
$result = wp_update_post( array(
'ID' => $post->ID,
'post_status' => 'publish',
), true );
if ( is_wp_error( $result ) ) {
return $result;
}
return array( 'published' => true );
}
meta.annotations.destructive is true here, same reasoning Course 6’s bulk-edit commit step
used, this is the one ability in the whole course that can make something public, and it should
read as the deliberate action it is to any MCP client or agent inspecting it.
Step 4: Wiring it into a real editorial routine
Test it
wp-env run cli wp eval '
$pending = wp_get_ability( "translation-review/list-pending" )->execute( array( "post_type" => "post" ) );
print_r( $pending );
'
Pick one post ID from the output, read it against its source in wp-admin, then approve it:
wp-env run cli wp eval '
$result = wp_get_ability( "translation-review/approve" )->execute( array(
"post_id" => 55,
"reviewer_confirmed" => true,
) );
print_r( $result );
'
Confirm the post’s status is now publish, and confirm calling approve again with
reviewer_confirmed omitted or false returns the not_confirmed error rather than publishing
anything.
Recap
translation-review/list-pending surfaces every draft translation waiting on a bilingual
reviewer without changing anything, and translation-review/approve is the single, explicit,
destructive-annotated ability that can move one from draft to published, and only when
reviewer_confirmed is set to true. This is the same human-in-the-loop discipline Course 25
built for original content, applied here to translation: a clear job at the review stage, and no
path from AI output straight to a public, non-source-language page without it.
Resources & further reading
- Human-in-the-Loop Editing and Fact-Checking, this track, Course 25
- wp_update_post() function reference, developer.wordpress.org
- Abilities API reference, developer.wordpress.org