Skip to content

WPML Translation Workflow — Claude Code + Vault

Created 2026-03-10
Updated 2026-03-10
Status draft
Tags websitemultilingualtranslationwpmlworkflowautomation

Goal: Use Claude Code to translate baseworks.com content into multiple languages via WPML, with translation files stored in the Obsidian vault as the source of truth.

Approach: Keep WPML (lifetime deal). Add a small mu-plugin (~100 lines) that exposes REST endpoints for creating and linking WPML translations programmatically. Claude Code drafts translations, stores them in the vault, and pushes them to WordPress through these endpoints.

Priority languages: French, Spanish (first), then Russian, Chinese, German (later).


1. Claude Code reads English page content (via WP REST API)
2. Claude Code writes the translation (stored as JSON in the vault)
3. Claude Code pushes the translation to WordPress (via custom REST endpoint)
4. WPML links the translated post to the English original
5. WPML handles hreflang, language switcher, URL routing — all its normal jobs

WordPress and WPML handle everything they’re already good at (routing, hreflang, language switcher, WooCommerce). Claude Code handles the part that’s currently manual (writing and deploying translations).


In WP Admin → WPML → Languages, add:

  • English (already active)
  • Japanese (already active)
  • French (fr)
  • Spanish (es)
  • Russian (ru) — later
  • Chinese Simplified (zh-hans) — later
  • German (de) — later

A small mu-plugin that adds custom REST endpoints for Claude Code to create and manage translations without needing WP admin access.

File: wp-content/mu-plugins/bw-wpml-translation-api.php

<?php
/**
* Baseworks WPML Translation API
*
* Adds REST endpoints for programmatic WPML translation management.
* Used by Claude Code to create/update translations from the vault.
*/
add_action('rest_api_init', function () {
// POST /wp-json/bw/v1/translate
// Creates or updates a translation for a given post
register_rest_route('bw/v1', '/translate', [
'methods' => 'POST',
'callback' => 'bw_wpml_create_translation',
'permission_callback' => function () {
return current_user_can('edit_posts');
},
]);
// GET /wp-json/bw/v1/translation-status
// Returns translation status for a post across all languages
register_rest_route('bw/v1', '/translation-status/(?P<post_id>\d+)', [
'methods' => 'GET',
'callback' => 'bw_wpml_translation_status',
'permission_callback' => function () {
return current_user_can('read');
},
]);
// GET /wp-json/bw/v1/untranslated
// Returns posts that lack translations in a given language
register_rest_route('bw/v1', '/untranslated/(?P<lang>[a-z\-]+)', [
'methods' => 'GET',
'callback' => 'bw_wpml_untranslated',
'permission_callback' => function () {
return current_user_can('read');
},
]);
});
function bw_wpml_create_translation($request) {
$source_post_id = (int) $request->get_param('source_post_id');
$target_lang = sanitize_text_field($request->get_param('language'));
$translated_title = $request->get_param('title');
$translated_body = $request->get_param('content');
$translated_slug = sanitize_title($request->get_param('slug') ?: $translated_title);
if (!$source_post_id || !$target_lang || !$translated_title) {
return new WP_Error('missing_params', 'source_post_id, language, and title are required.', ['status' => 400]);
}
$source_post = get_post($source_post_id);
if (!$source_post) {
return new WP_Error('not_found', 'Source post not found.', ['status' => 404]);
}
// Get the source post's WPML element type and trid
$element_type = 'post_' . $source_post->post_type;
$source_lang_details = apply_filters('wpml_element_language_details', null, [
'element_id' => $source_post_id,
'element_type' => $source_post->post_type,
]);
if (!$source_lang_details || !isset($source_lang_details->trid)) {
return new WP_Error('wpml_error', 'Could not get WPML language details for source post.', ['status' => 500]);
}
$trid = $source_lang_details->trid;
$source_lang = $source_lang_details->language_code;
// Check if a translation already exists for this language
$existing_translations = apply_filters('wpml_get_element_translations', null, $trid, $element_type);
$existing_translation_id = null;
if (!empty($existing_translations[$target_lang])) {
$existing_translation_id = $existing_translations[$target_lang]->element_id;
}
// Create or update the translated post
$post_data = [
'post_title' => $translated_title,
'post_content' => $translated_body ?: '',
'post_status' => $source_post->post_status,
'post_type' => $source_post->post_type,
'post_name' => $translated_slug,
];
if ($existing_translation_id) {
// Update existing translation
$post_data['ID'] = $existing_translation_id;
$result_id = wp_update_post($post_data, true);
} else {
// Create new translation
$result_id = wp_insert_post($post_data, true);
}
if (is_wp_error($result_id)) {
return $result_id;
}
// Link the translation to the source post via WPML
do_action('wpml_set_element_language_details', [
'element_id' => $result_id,
'element_type' => $element_type,
'trid' => $trid,
'language_code' => $target_lang,
'source_language_code' => $source_lang,
]);
// Copy SEOPress meta if the source has it
$seo_fields = ['_seopress_titles_title', '_seopress_titles_desc', '_seopress_social_fb_title',
'_seopress_social_fb_desc', '_seopress_social_twitter_title', '_seopress_social_twitter_desc'];
$translated_meta = $request->get_param('meta') ?: [];
foreach ($seo_fields as $field) {
if (isset($translated_meta[$field])) {
update_post_meta($result_id, $field, sanitize_text_field($translated_meta[$field]));
}
}
return [
'success' => true,
'translated_post_id' => $result_id,
'source_post_id' => $source_post_id,
'language' => $target_lang,
'trid' => $trid,
'url' => get_permalink($result_id),
'action' => $existing_translation_id ? 'updated' : 'created',
];
}
function bw_wpml_translation_status($request) {
$post_id = (int) $request->get_param('post_id');
$post = get_post($post_id);
if (!$post) {
return new WP_Error('not_found', 'Post not found.', ['status' => 404]);
}
$element_type = 'post_' . $post->post_type;
$lang_details = apply_filters('wpml_element_language_details', null, [
'element_id' => $post_id,
'element_type' => $post->post_type,
]);
if (!$lang_details) {
return new WP_Error('wpml_error', 'Could not get WPML language details.', ['status' => 500]);
}
$translations = apply_filters('wpml_get_element_translations', null, $lang_details->trid, $element_type);
$active_languages = apply_filters('wpml_active_languages', null, []);
$status = [];
foreach ($active_languages as $lang_code => $lang_info) {
$status[$lang_code] = [
'language' => $lang_info['translated_name'],
'translated' => isset($translations[$lang_code]),
'post_id' => isset($translations[$lang_code]) ? $translations[$lang_code]->element_id : null,
'url' => isset($translations[$lang_code]) ? get_permalink($translations[$lang_code]->element_id) : null,
];
}
return [
'source_post_id' => $post_id,
'source_lang' => $lang_details->language_code,
'trid' => $lang_details->trid,
'translations' => $status,
];
}
function bw_wpml_untranslated($request) {
$target_lang = sanitize_text_field($request->get_param('lang'));
$post_type = sanitize_text_field($request->get_param('post_type') ?: 'page');
$per_page = (int) ($request->get_param('per_page') ?: 50);
global $wpdb;
// Get posts in the default language that lack a translation in the target language
$results = $wpdb->get_results($wpdb->prepare("
SELECT p.ID, p.post_title, p.post_type, t.trid
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->prefix}icl_translations t
ON t.element_id = p.ID
AND t.element_type = CONCAT('post_', p.post_type)
AND t.language_code = (
SELECT default_locale FROM {$wpdb->prefix}icl_languages
WHERE code = t.language_code
LIMIT 1
)
WHERE p.post_status = 'publish'
AND p.post_type = %s
AND t.source_language_code IS NULL
AND t.trid NOT IN (
SELECT trid FROM {$wpdb->prefix}icl_translations
WHERE language_code = %s
)
ORDER BY p.post_title ASC
LIMIT %d
", $post_type, $target_lang, $per_page));
// Simplified fallback if the complex query fails
if ($wpdb->last_error) {
// Fallback: get all published posts and filter in PHP
$all_posts = get_posts([
'post_type' => $post_type,
'post_status' => 'publish',
'numberposts' => $per_page,
'suppress_filters' => false,
]);
$results = [];
foreach ($all_posts as $post) {
$lang_details = apply_filters('wpml_element_language_details', null, [
'element_id' => $post->ID,
'element_type' => $post->post_type,
]);
if ($lang_details && $lang_details->source_language_code === null) {
$translations = apply_filters('wpml_get_element_translations', null, $lang_details->trid, 'post_' . $post->post_type);
if (!isset($translations[$target_lang])) {
$results[] = (object) [
'ID' => $post->ID,
'post_title' => $post->post_title,
'post_type' => $post->post_type,
'trid' => $lang_details->trid,
];
}
}
}
}
return [
'language' => $target_lang,
'post_type' => $post_type,
'count' => count($results),
'untranslated' => array_map(function ($r) {
return [
'post_id' => $r->ID,
'title' => $r->post_title,
'post_type' => $r->post_type,
'trid' => $r->trid,
'edit_url' => admin_url("post.php?post={$r->ID}&action=edit"),
];
}, $results),
];
}

Claude Code authenticates to the REST API via application passwords (built into WordPress 5.6+):

  1. In WP Admin → Users → Patrick’s profile → Application Passwords
  2. Create a new application password named “Claude Code”
  3. Store the credentials securely (e.g., in ~/.baseworks-api-credentials with chmod 600)
~/.baseworks-api-credentials
BASEWORKS_WP_USER="patrick"
BASEWORKS_WP_APP_PASSWORD="xxxx xxxx xxxx xxxx xxxx xxxx"
BASEWORKS_WP_URL="https://baseworks.com"

Terminal window
# Claude Code runs this to find untranslated pages for French
curl -s -u "$BASEWORKS_WP_USER:$BASEWORKS_WP_APP_PASSWORD" \
"$BASEWORKS_WP_URL/wp-json/bw/v1/untranslated/fr?post_type=page" | jq .

Returns a list of English pages with no French translation.

Terminal window
# Get the full content of a specific page
curl -s -u "$BASEWORKS_WP_USER:$BASEWORKS_WP_APP_PASSWORD" \
"$BASEWORKS_WP_URL/wp-json/wp/v2/pages/12345" | jq '{title: .title.rendered, content: .content.rendered, slug: .slug}'

Claude Code reads the English content, writes the French translation, and saves it in the vault:

Vault file: 03-resources/translations/fr/pages/about.json

{
"_meta": {
"source_post_id": 12345,
"source_url": "/about/",
"language": "fr",
"translated_by": "claude-code",
"translated_date": "2026-03-10",
"status": "draft"
},
"title": "À propos de Baseworks",
"slug": "a-propos",
"content": "<p>Baseworks fournit une approche structurée de l'éducation physique...</p>",
"seo": {
"_seopress_titles_title": "À propos de Baseworks | Éducation physique",
"_seopress_titles_desc": "Baseworks est un cadre d'éducation physique fondé sur..."
}
}

Two options:

  • In the vault: Patrick or Asia reads the JSON file and edits if needed
  • In SimpleLocalize (optional): If you later add SimpleLocalize, translations can be reviewed in its web UI before deploying

After review, update status to "approved".

Terminal window
# Claude Code pushes the approved translation to WPML
curl -s -X POST \
-u "$BASEWORKS_WP_USER:$BASEWORKS_WP_APP_PASSWORD" \
"$BASEWORKS_WP_URL/wp-json/bw/v1/translate" \
-H "Content-Type: application/json" \
-d '{
"source_post_id": 12345,
"language": "fr",
"title": "À propos de Baseworks",
"slug": "a-propos",
"content": "<p>Baseworks fournit une approche structurée...</p>",
"meta": {
"_seopress_titles_title": "À propos de Baseworks | Éducation physique",
"_seopress_titles_desc": "Baseworks est un cadre d'éducation physique fondé sur..."
}
}'

WPML automatically:

  • Creates the /fr/a-propos/ URL
  • Links it as the French translation of /about/
  • Adds hreflang tags to both pages
  • Includes it in the language switcher
  • Adds it to the multilingual sitemap
Terminal window
# Check translation status
curl -s -u "$BASEWORKS_WP_USER:$BASEWORKS_WP_APP_PASSWORD" \
"$BASEWORKS_WP_URL/wp-json/bw/v1/translation-status/12345" | jq .

Straightforward — the workflow above covers these. Claude Code reads wp/v2/pages/{id} or wp/v2/posts/{id}, translates the content, pushes via bw/v1/translate.

Elementor stores its layout data in postmeta (_elementor_data). WPML handles Elementor translation by:

  1. Duplicating the Elementor page structure
  2. Registering each text widget/element as a translatable string

For Claude Code: Two approaches:

Approach A: Use WPML’s String Translation (recommended for Elementor)

  • WPML’s Translation Editor shows each Elementor text element as a separate string
  • Claude Code translates each string via the WPML String Translation API
  • The Elementor layout stays intact; only text changes

Approach B: Duplicate and edit via REST API

  • Use bw/v1/translate to create the translated post
  • Copy the source _elementor_data postmeta to the translated post
  • Use a second REST call to update _elementor_data with translated text nodes

Approach A is cleaner and what WPML is designed for. We can add a bw/v1/translate-strings endpoint to handle string-level translation for Elementor content.

WooCommerce products are a custom post type (product). The mu-plugin handles them the same way — post_type is just product instead of page.

Terminal window
# Translate a WooCommerce product
curl -s -X POST \
-u "$BASEWORKS_WP_USER:$BASEWORKS_WP_APP_PASSWORD" \
"$BASEWORKS_WP_URL/wp-json/bw/v1/translate" \
-d '{
"source_post_id": 67890,
"language": "fr",
"title": "Sessions de pratique — Introduction",
"content": "<p>Votre première session d'introduction...</p>",
"slug": "sessions-de-pratique-introduction"
}'

Product attributes and variations: These need additional handling. WPML manages them through its WooCommerce Multilingual add-on. If WCML is part of the lifetime deal, it handles:

  • Product attribute translations
  • Variation descriptions
  • Category and tag translations
  • Translated product slugs
  • Cart/checkout string translations

WPML manages menu translations through its own interface. Claude Code can assist by:

  1. Reading the English menu structure via wp/v2/menus
  2. Drafting translated menu item labels
  3. Storing them in the vault for reference

Menu translation deployment may still need WP admin for initial setup, but subsequent text updates can go through string translation.

WPML String Translation handles these. Claude Code can:

  1. List untranslated strings via wp eval over SSH
  2. Translate them
  3. Push translations via wp eval with WPML’s icl_add_string_translation() function
Terminal window
# Via WP-CLI over SSH
ssh baseworks-web "cd /var/www/baseworks.com && \
wp eval '
icl_add_string_translation(
icl_get_string_id(\"Add to Cart\", \"WooCommerce\"),
\"fr\",
\"Ajouter au panier\",
ICL_TM_COMPLETE
);
' 2>/dev/null"

These already exist as standalone French pages in WordPress. During setup:

  1. Convert them to proper WPML translations (link them to the English source via the mu-plugin)
  2. Future updates go through the standard vault → translate → push workflow
  3. Termageddon-managed English pages: the French translation is separate content, not a Termageddon embed

03-resources/translations/
├── README.md # This workflow documentation
├── config/
│ └── languages.yml # Active languages and their status
├── fr/
│ ├── pages/
│ │ ├── about.json
│ │ ├── programs.json
│ │ ├── practice-sessions.json
│ │ ├── study-group.json
│ │ └── ...
│ ├── products/
│ │ ├── practice-session-intro.json
│ │ └── ...
│ ├── legal/
│ │ ├── privacy-policy.json
│ │ ├── cookie-policy.json
│ │ └── ...
│ └── strings/
│ ├── theme.json # Kadence theme strings
│ ├── woocommerce.json # WooCommerce UI strings
│ └── seopress.json # SEO metadata
├── es/
│ └── (same structure)
├── ja/
│ └── (same structure — migrated from current WPML)
└── (ru/, zh-hans/, de/ — added later)

Each file follows this structure:

{
"_meta": {
"source_post_id": 12345,
"source_url": "/about/",
"language": "fr",
"translated_by": "claude-code",
"translated_date": "2026-03-10",
"reviewed_by": null,
"reviewed_date": null,
"status": "draft",
"wpml_post_id": null,
"notes": "Initial translation"
},
"title": "À propos de Baseworks",
"slug": "a-propos",
"content": "...",
"seo": {
"_seopress_titles_title": "...",
"_seopress_titles_desc": "..."
}
}

Status values: draftapproveddeployed

After successful push to WordPress, Claude Code updates status to deployed and records wpml_post_id.


6. Session Flow — Typical Translation Session

Section titled “6. Session Flow — Typical Translation Session”

Example: “Translate the About page into French and Spanish”

Section titled “Example: “Translate the About page into French and Spanish””
Step 1: Claude Code checks what's untranslated
→ GET /bw/v1/untranslated/fr
→ GET /bw/v1/untranslated/es
Step 2: Claude Code reads the English About page
→ GET /wp/v2/pages/12345
Step 3: Claude Code reads the voice guides (for Baseworks tone)
→ Reads 03-resources/voice-guides/VOICE-GUIDE-UNIFIED.md
Step 4: Claude Code writes French and Spanish translations
→ Saves to vault: fr/pages/about.json, es/pages/about.json
→ Follows voice guide principles (no metaphors, positive framing, etc.)
Step 5: User reviews translations in vault (or says "looks good")
Step 6: Claude Code deploys both translations
→ POST /bw/v1/translate (French)
→ POST /bw/v1/translate (Spanish)
→ Updates vault files: status → "deployed", records wpml_post_id
Step 7: Claude Code verifies
→ GET /bw/v1/translation-status/12345
→ Confirms FR and ES translations are linked
→ Optionally checks the live URLs

Example: “Update the French Practice Sessions page — the English version changed”

Section titled “Example: “Update the French Practice Sessions page — the English version changed””
Step 1: Claude Code reads the updated English page
→ GET /wp/v2/pages/67890
Step 2: Claude Code reads the existing French translation from vault
→ Reads fr/pages/practice-sessions.json
Step 3: Claude Code identifies what changed and updates the French translation
Step 4: Claude Code pushes the update
→ POST /bw/v1/translate (updates existing French post)
Step 5: Claude Code updates the vault file
→ translated_date updated, status → "deployed"

French is the most pressing due to Quebec Bill 96 compliance and the Montreal audience.

Pages to translate first:

  1. All policy/legal pages (some already exist as standalone — convert to WPML pairs)
  2. Homepage
  3. Programs overview
  4. Practice Sessions landing page
  5. Study Group landing page
  6. About page
  7. Contact page
  8. WooCommerce checkout flow strings
  9. WooCommerce product pages

Broader audience reach. Same page priority as French.

One at a time. Each language follows the same workflow — Claude Code translates, stores in vault, pushes to WPML.


All translations must follow the Baseworks voice guides:

  • VOICE-GUIDE-UNIFIED.md applies to all languages
  • No generated metaphors
  • Positive framing (describe what something IS)
  • No exclamation marks in professional contexts
  • “Fitness” is avoided — use appropriate equivalents in each language
  1. Claude Code drafts — writes the translation following voice guides
  2. Vault review — Patrick or Asia reviews the JSON file, edits if needed, sets status: approved
  3. Deploy — Claude Code pushes approved translations to WordPress
  4. Live check — verify the page renders correctly in the target language

When English content changes:

  1. Claude Code detects the change (or user flags it)
  2. Claude Code updates the corresponding translation(s) in the vault
  3. After review, deploys the update
  4. Vault files track the translation date so stale translations are visible

  • Enable French and Spanish in WPML (WP Admin → WPML → Languages)
  • Deploy the mu-plugin (bw-wpml-translation-api.php) to the server
  • Create an application password for Claude Code
  • Store API credentials on Patrick’s Mac and VPS
  • Create the vault file structure (03-resources/translations/)
  • Test the mu-plugin endpoints on staging first
  • Convert existing standalone French policy pages to WPML translation pairs
  • Export existing Japanese WPML translations to vault JSON files (for backup)
  • Enable the language in WPML
  • Create the language directory in the vault (03-resources/translations/{lang}/)
  • Translate and deploy pages in priority order (see Phase rollout above)
  • Verify language switcher shows the new language
  • Verify hreflang tags are correct
  • Test WooCommerce checkout in the new language

Additional cost: $0. The lifetime WPML deal covers everything. The mu-plugin is custom code deployed to the existing server. No new services or subscriptions needed.

If you later want a TMS web UI for reviewing translations (instead of reviewing JSON files in the vault), SimpleLocalize can be added at €35/mo — but it’s optional and not required for this workflow.


  1. Elementor complex layouts — WPML duplicates the Elementor structure per language. For pages with complex visual layouts, the first translation of each page may need some manual Elementor adjustment (column widths, text that’s longer in French, etc.). Subsequent text-only updates can go through the API.

  2. WooCommerce emails — WPML + WCML handles email translations if WCML is part of the lifetime deal. If not, the mu-plugin approach from the edge plan (locale switching based on order meta) works as a lightweight alternative.

  3. Dynamic JS content — Content rendered client-side by JavaScript (e.g., AJAX-loaded widgets) is translated by WPML if it goes through WordPress’s gettext system. Custom JS-rendered content may need manual string registration.

  4. Menu translation — WPML’s menu sync feature duplicates menus per language. Initial setup is manual (WP Admin → Appearance → Menus), but text updates can go through string translation.

  5. Slug translation — WPML translates URL slugs (e.g., /about//fr/a-propos/). The mu-plugin passes the translated slug; WPML handles the URL rewriting.


This workflow uses WPML as the multilingual engine. The edge plan (03-resources/translation/wpml-migration-plan.md) describes a future architecture where WPML is removed entirely and replaced by a Cloudflare Worker.

The two plans are complementary:

  • Now: Use this WPML workflow. Translate content. Build up the vault translation files.
  • Future (if needed): The vault translation files become the source for the Cloudflare Worker. The migration path is: export WPML translations → they’re already in the vault as JSON → deploy to Cloudflare KV → switch over.

The vault file structure is intentionally the same in both plans, so the translations you build now are portable.