Lesson 2 built the WPML version of this ability. If your site runs Polylang instead, the
underlying idea is the same, generate a translated draft with the AI Client SDK, then register it
as a real translation, only the plugin’s own functions are different. This lesson is the Polylang
equivalent, built around pll_get_post(), pll_get_post_translations(),
pll_set_post_language(), and pll_save_post_translations(), the current, non-deprecated
functions Polylang’s own developer documentation lists for this exact job.
Polylang (or Polylang Pro) active, with at least two languages configured under Languages in wp-admin. Course 6’s translation-workflow project for the draft-only generation pattern this lesson reuses.
Step 1: Register the ability
The input shape matches Lesson 2’s, a post ID and a target language, here a Polylang two-letter
language slug (es, fr, de) rather than a WPML language code, since the two plugins don’t
necessarily use identical codes for every locale.
// File: polylang-translation-pipeline/polylang-translation-pipeline.php
add_action( 'wp_abilities_api_init', function () {
wp_register_ability(
'polylang-translation-pipeline/translate-post',
array(
'label' => __( 'Translate post via Polylang', 'polylang-translation-pipeline' ),
'description' => __( 'Generates a translated draft of a post and registers it as a real Polylang translation in the given language slug. Returns the existing translation instead of duplicating it if one already exists.', 'polylang-translation-pipeline' ),
'category' => 'content',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'post_id' => array( 'type' => 'integer' ),
'target_language' => array( 'type' => 'string', 'description' => 'A Polylang language slug, e.g. es, fr, de.' ),
),
'required' => array( 'post_id', 'target_language' ),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'translated_post_id' => array( 'type' => 'integer' ),
'edit_url' => array( 'type' => 'string' ),
'already_existed' => array( 'type' => 'boolean' ),
),
),
'permission_callback' => function ( $input ) {
return current_user_can( 'edit_post', $input['post_id'] );
},
'execute_callback' => 'polylang_translation_pipeline_translate',
'meta' => array(
'annotations' => array( 'destructive' => false ),
'mcp' => array( 'public' => true ),
),
)
);
} );
Step 2: Check for an existing translation with pll_get_post()
pll_get_post( $post_id, $slug ) returns the translated post’s ID in the given language slug, or
nothing if no translation exists yet in that language, exactly the duplicate guard this ability
needs before generating anything.
// File: polylang-translation-pipeline/polylang-translation-pipeline.php
function polylang_translation_pipeline_translate( $input ) {
$source = get_post( $input['post_id'] );
if ( ! $source ) {
return new WP_Error( 'not_found', 'Post not found.' );
}
$target_language = sanitize_text_field( $input['target_language'] );
$existing_id = pll_get_post( $source->ID, $target_language );
if ( $existing_id ) {
return array(
'translated_post_id' => (int) $existing_id,
'edit_url' => get_edit_post_link( $existing_id, 'raw' ),
'already_existed' => true,
);
}
return polylang_translation_pipeline_create_translation( $source, $target_language );
}
Step 3: Generate the draft, then link it with pll_set_post_language() and pll_save_post_translations()
// File: polylang-translation-pipeline/polylang-translation-pipeline.php
function polylang_translation_pipeline_create_translation( $source, $target_language ) {
$prompt = <<<PROMPT
Translate the following post into the language identified by the code "{$target_language}".
Preserve meaning, tone, and paragraph structure, do not summarize or shorten it. Return the
translated title on the first line prefixed with "Title: ", then the translated body after a
blank line.
Title: {$source->post_title}
Content: {$source->post_content}
PROMPT;
$response = wp_ai_client_prompt( $prompt )
->using_model_preference( 'anthropic/claude', 'openai/gpt', 'google/gemini' )
->generate_text();
if ( empty( $response ) ) {
return new WP_Error( 'empty_response', 'Translation failed, no draft was created.' );
}
list( $title, $body ) = polylang_translation_pipeline_split_title_body( $response, $source->post_title );
$translated_id = wp_insert_post( array(
'post_title' => sanitize_text_field( $title ),
'post_content' => wp_kses_post( $body ),
'post_status' => 'draft', // Same rule as Course 6's project, machine translation is never auto-published.
'post_type' => $source->post_type,
), true );
if ( is_wp_error( $translated_id ) ) {
return $translated_id;
}
// pll_get_post_translations() returns the source post's own translation group, keyed by
// language slug, which is also how we find the source's own language without a second lookup.
$translations = pll_get_post_translations( $source->ID );
pll_set_post_language( $translated_id, $target_language );
$translations[ $target_language ] = $translated_id;
pll_save_post_translations( $translations );
return array(
'translated_post_id' => $translated_id,
'edit_url' => get_edit_post_link( $translated_id, 'raw' ),
'already_existed' => false,
);
}
function polylang_translation_pipeline_split_title_body( $response, $fallback_title ) {
if ( preg_match( '/^Title:\s*(.+)$/mi', $response, $m ) ) {
return array( trim( $m[1] ), trim( str_replace( $m[0], '', $response ) ) );
}
return array( $fallback_title, $response );
}
pll_get_post_translations() on the source post returns its full translation group as an
associative array, language slug as key, post ID as value, including the source post’s own entry.
Adding the new post’s ID under the target language slug and passing that whole array to
pll_save_post_translations() commits the new post into the same group, rather than creating a
second, disconnected one.
pll_save_post_translations() expects the complete translation group, not just the one new
entry. Passing an array containing only { $target_language: $translated_id } risks Polylang
treating that as the entire group and losing track of the other existing translations. Always
start from pll_get_post_translations()’s current result, add your one new entry to it, and save
the whole thing back.
Test it
wp-env run cli wp eval '
$result = wp_get_ability( "polylang-translation-pipeline/translate-post" )->execute( array(
"post_id" => 42,
"target_language" => "fr",
) );
print_r( $result );
'
wp-env run cli wp eval 'var_dump( pll_get_post( 42, "fr" ) );'
Confirm the second command returns the same post ID as translated_post_id from the first, and
that Polylang’s own language column in wp-admin’s post list now shows the French translation
linked to post 42.
Recap
pll_get_post() doubles as a duplicate guard before generating anything, pll_get_post_translations()
supplies both the source post’s existing translation group and, implicitly, its own language slug,
and pll_set_post_language() plus pll_save_post_translations() commit the new post into that
same group. The generation half of this ability, the wp_ai_client_prompt() call and the
draft-only wp_insert_post(), is identical to Lesson 2’s WPML version, only the linking
mechanism changes to match the plugin your site actually runs.
Resources & further reading
- Polylang function reference, polylang.pro
- Polylang, polylang.pro
- wp_insert_post() function reference, developer.wordpress.org