Lesson 1 named the gap plainly: Course 6’s translation project links a translated draft to its source through a custom meta key, which WPML never sees. This lesson builds the WPML-native version of that same idea, an ability that generates a translated draft with the AI Client SDK, then registers it inside WPML’s own translation group so the language switcher, the admin translations column, and translated permalinks all recognize it correctly.
WPML active, with Multilingual CMS or Multilingual Blog mode configured and at least two languages set up under WPML > Languages. Course 6’s translation-workflow project, this lesson reuses its draft-only, review-first behavior and changes how the translation is linked, not whether it publishes.
Step 1: Register the ability
Same input shape as Course 6’s project, a post ID and a target language, except here
target_language is a WPML language code (es, fr, de) rather than a free-text language
name, since that’s what every WPML hook in this lesson expects.
// File: wpml-translation-pipeline/wpml-translation-pipeline.php
add_action( 'wp_abilities_api_init', function () {
wp_register_ability(
'wpml-translation-pipeline/translate-post',
array(
'label' => __( 'Translate post via WPML', 'wpml-translation-pipeline' ),
'description' => __( 'Generates a translated draft of a post and registers it as a real WPML translation in the given language code. Returns the existing translation instead of duplicating it if one already exists.', 'wpml-translation-pipeline' ),
'category' => 'content',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'post_id' => array( 'type' => 'integer' ),
'target_language' => array( 'type' => 'string', 'description' => 'A WPML language code, 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' => 'wpml_translation_pipeline_translate',
'meta' => array(
'annotations' => array( 'destructive' => false ),
'mcp' => array( 'public' => true ),
),
)
);
} );
Step 2: Check for an existing translation first
This is where wpml_object_id earns its place, not as a one-off lookup, but as the guard that
stops this ability from generating a duplicate translation every time it’s called for a language
pair that already has one.
// File: wpml-translation-pipeline/wpml-translation-pipeline.php
function wpml_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'] );
// The real, current WPML lookup: does a translation already exist in this language?
$existing_id = apply_filters( 'wpml_object_id', $source->ID, $source->post_type, false, $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,
);
}
// No translation yet, generate and link one.
return wpml_translation_pipeline_create_translation( $source, $target_language );
}
Passing false for $return_original_if_missing matters here. With true, the filter would
hand back the source post’s own ID when no translation exists, and this code would read that as
“a translation already exists” and stop, never generating one. icl_object_id() performs the
same underlying lookup, but it’s the older function this filter has superseded, new WPML
integration code should call wpml_object_id directly.
Step 3: Generate the draft, then link it into WPML’s translation group
// File: wpml-translation-pipeline/wpml-translation-pipeline.php
function wpml_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 ) = wpml_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;
}
// Read the source post's own language and translation group, then register the new post
// inside that same group in the target language.
$source_language = apply_filters( 'wpml_post_language_details', null, $source->ID );
$trid = apply_filters( 'wpml_element_trid', null, $source->ID, 'post_' . $source->post_type );
do_action( 'wpml_set_element_language_details', array(
'element_id' => $translated_id,
'element_type' => 'post_' . $source->post_type,
'trid' => $trid,
'language_code' => $target_language,
'source_language_code' => $source_language['language_code'],
) );
return array(
'translated_post_id' => $translated_id,
'edit_url' => get_edit_post_link( $translated_id, 'raw' ),
'already_existed' => false,
);
}
function wpml_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 );
}
wpml_element_trid returns the translation group ID (trid) the source post already belongs to,
and wpml_set_element_language_details is the action that actually writes the new post into that
group in the target language. Once this action runs, WPML’s own language switcher, the
Translations column in wp-admin, and wpml_object_id lookups from anywhere else in your code all
see the relationship immediately, none of it depends on a custom meta key.
Test it
wp-env run cli wp eval '
$result = wp_get_ability( "wpml-translation-pipeline/translate-post" )->execute( array(
"post_id" => 42,
"target_language" => "es",
) );
print_r( $result );
'
Open the source post in wp-admin and confirm WPML’s own translation editor now lists a Spanish
translation, then run the same command again with the same arguments and confirm
already_existed comes back true the second time, with the same translated_post_id, rather
than a second draft being created.
If you omit trid from the wpml_set_element_language_details call (or pass a stale one), WPML
can create a new, separate translation group instead of adding the new post to the source post’s
existing one. That produces a post that’s technically registered as a translation but not of the
post you meant, always fetch a fresh trid from wpml_element_trid right before you call
wpml_set_element_language_details, don’t cache or hardcode it.
Recap
This ability replaces Course 6’s meta-key linking with the real thing: wpml_object_id guards
against generating a duplicate translation, wpml_post_language_details and wpml_element_trid
read the source post’s language and translation group, and wpml_set_element_language_details
writes the new draft into that group in the target language. The draft-only publishing behavior
from Course 6 carries over unchanged, only the linking mechanism is now WPML’s own.
Resources & further reading
- wpml_object_id, wpml.org
- wpml_element_trid, wpml.org
- wpml_post_language_details, wpml.org
- WPML hooks reference, wpml.org