🌐 Also available in: Deutsch · Français · Español

For almost two years this blog spoke exactly one language. English is the lingua franca of sysadmins, so that seemed fine. But I’m German, a good part of my readers aren’t native English speakers either, and when people search for “ZFS RAIDZ1 NAS Debian” in German, French or Spanish, they get results in German, French or Spanish. Not mine.
So this week the blog learned three new languages. Every published post is now available in English, German, French and Spanish, with a language switcher in the menu and a little “🌐 Also available in” line above each post. This post is the making-of: the plugin, the pipeline, the bug that 404’d an entire language, and what I learned about running a small army of AI agents in parallel. Same workflow as always: I make the calls, my AI co-admin (Claude) did the heavy lifting.
🎯 Why bother? More readers, hopefully
Let’s be honest about the motivation: I expect more visitors from search engines. Google, Bing and DuckDuckGo match search queries to pages in the searcher’s language. A German admin looking for a fail2ban fix is far more likely to type German keywords, and a page that exists in German has a real chance to show up there. With proper hreflang tags the search engine also knows that the four versions belong together, so it can show each person the one in their language instead of treating them as duplicate content.
Will it work? No idea yet. I counted my real, human readers a while ago (spoiler: far fewer than the request logs suggest), and I’ll count again in a few months. If the translated pages bring in people who would never have found the English original, the experiment paid off. If not, I’ll write that post too.
🧩 The plugin: Polylang, and why
I picked Polylang, in its free version. My shortlist criteria were simple: free, open source (GPLv3), no account, no cloud service and no machine translation sending my content somewhere. Polylang does none of the translating itself. It just knows which post is in which language and which posts belong together, and it does that by storing the language as an ordinary WordPress taxonomy. No extra tables, nothing exotic in the database.
The settings I chose, and why:
- English stays the default, without
/en/in the URL. Every existing link, bookmark and backlink keeps working. The new languages live under/de/,/fr/and/es/. - No browser language detection. Search engine crawlers don’t send a preferred language, and I don’t want a visitor who clicked an English link to be bounced somewhere else. You get what you clicked; the switcher is right there if you want another language.
- Media is not translated. Screenshots are screenshots. The alt texts and captions in the posts are translated, the image files are shared.
- Categories and tags exist per language (“IT Security” becomes “IT-Sicherheit”, “Sécurité informatique” and “Seguridad informática”), so the archive pages work in every language too.
The SEO plumbing came for free: Polylang adds the hreflang alternates to every page, and Yoast’s XML sitemap picks up the translations automatically. The menu got four little flags as a language switcher, and a small must-use plugin (deployed by my Ansible role, like everything else on this server) prints the “Also available in” line, but only for languages in which the post really exists.

🤖 Being upfront about the AI part
All translations are made with AI. I say so on the AI Usage Notice page, which now has a “Translations” section: English is the original, the other languages are AI translations checked by a second, independent AI pass. If something reads oddly in German, French or Spanish, the English version is the reference, and I’m happy to hear about it.
🧪 The pilot, and the 404 that ate a whole language
Before touching a hundred posts, I translated exactly one: my front-page-on-phones post. Good thing, because the first result was spectacular in the wrong way: every German page returned a 404. The posts existed, Polylang knew about them, the switcher linked to them, and WordPress said “never heard of it”.
The culprit: rewrite rules. I had set Polylang up via wp-cli and flushed the rewrite rules from the command line. On the CLI, Polylang’s language prefix filter isn’t fully in place, so WordPress happily generated a fresh set of rules that knew nothing about /de/. The fix was almost embarrassing: delete the cached rules and let the next normal front-end request rebuild them, this time with Polylang fully loaded.
wp eval "delete_option('rewrite_rules');"
# then just load any page once – WordPress rebuilds the rules with the language prefixesLesson noted: anything that depends on request context (languages, themes, some caching plugins) should regenerate its state in a real request, not in a CLI session.
🏭 The pipeline: 111 posts × 3 languages
111 published posts times three languages makes 333 translations. Doing that by hand in the editor was never an option, so it became a pipeline. Each post goes through five steps:
- Export. A wp-cli script dumps every post as Gutenberg HTML. Code blocks are cut out and replaced by placeholders like
<!--PLLCODE 3-->; the original code goes into a separate JSON file. A translator can’t “improve” a shell command it never sees. - Translate. An AI agent translates the HTML into German, French and Spanish, following a style guide (more on that below), plus title, excerpt and meta description per language.
- Check, mechanically. A small Python script compares each translation with the original: same sequence of Gutenberg block comments, same placeholders, the same set of link targets and image sources, the same number of table rows and list items. Anything off and the translation goes back.
- Review, independently. A second agent, which never saw the translation being written, compares original and translations sentence by sentence and writes its corrections as exact find-and-replace pairs with a reason for each. Every “old” string must occur exactly once, otherwise the change is rejected.
- Import. A database dump first, then a script running as
www-dataputs the code blocks back, publishes the translations with the original date, assigns the per-language categories and tags, reuses the featured image, sets the Yoast meta description and links the four versions together in Polylang.
The heart of the mechanical check is a dozen lines. It doesn’t understand a single word of any language, and that’s the point:
def feats(h):
return {
'blocks': re.findall(r'<!-- (/?wp:[a-z0-9/-]+)', h),
'code': sorted(re.findall(r'<!--PLLCODE \d+-->', h)),
'href': sorted(re.findall(r'href="([^"]*)"', h)),
'src': sorted(re.findall(r'src="([^"]*)"', h)),
'tr': h.count('<tr'), 'li': h.count('<li'), 'img': h.count('<img'),
'inline_code': len(re.findall(r'<code>', h)),
}
# original and translation must produce identical features📏 A style guide, like a real translation agency
Consistency across 333 texts doesn’t happen by accident. The style guide fixes the tone (German du, French vous, Spanish tú), a glossary (“hardening” is always “Härtung”, “durcissement”, “bastionado”), number formats (2,6 s instead of 2.6 s), French typography with non-breaking spaces before : ; ? !, and a list of things that are never touched: commands, paths, hostnames, version numbers, error messages, product names. Links to related posts keep their English titles, with a short “(in English)” note, until those posts are translated too.
🔍 Was the review worth it?
Absolutely. Most translations were good, but “good” isn’t “right”. For the first 26 posts, the reviewer found about a hundred things to fix, roughly evenly spread over the three languages; only two posts came back with nothing at all. Most were style polish, but a few were real meaning errors that a fluent-sounding text hides perfectly:
- “a longer memory” (meaning: longer log retention) had become “more RAM” in Spanish.
- “customer-facing service” had become “customer service” in French.
- A table column “Focus” had turned into “Priority”. Plausible, wrong.
✏️ TODO (Raphael): final numbers when all 111 posts are through.
🐝 Subagents: one dispatcher, many workers
Here’s where it got interesting. Translating one post costs an AI a lot of reading and writing: the style guide, the original, three translations. Doing 111 of those in a single conversation would bury the conversation in text long before the end. So the main session became a dispatcher and did almost no translating itself:
- It starts a translator agent per post. Each one is a fresh instance with a clean context: it reads the style guide and exactly one post, writes the files, runs the check and reports back with three lines.
- When a translator reports, the dispatcher starts a review agent for that post, running on a smaller, cheaper model (Sonnet), and immediately the next translator.
- When a review comes back, the dispatcher applies the corrections, runs the check again and uploads.
Three to four translators and a couple of reviewers ran at the same time. The dispatcher’s own context stayed small, because all it ever saw were short reports. Splitting writer and reviewer has a second benefit beyond parallelism: the reviewer has no attachment to the translation. It didn’t write it, it doesn’t “remember” why a sentence was phrased that way, it just compares.
✅ When parallel agents pay off
- Independent tasks. Post 1370 doesn’t care about post 1373. No shared state, no ordering problems.
- Clear input, clear output. “Read these files, write those files, run this check.” An agent with a crisp contract needs no back-and-forth.
- A mechanical gate. Because the Python check catches broken structure, I don’t need to trust each agent; I trust the gate.
- Fresh context saves budget. A worker that only reads what it needs is cheaper than one long conversation that drags every previous post along.
❌ When they don’t
- Interlocked steps. Setting up Polylang, chasing the 404, building the import script: each step depended on the result of the last. That was one conversation, one brain, no fan-out.
- Small stuff. Each agent starts cold and has to read the style guide first. For a one-line fix, the spin-up costs more than the work.
- Muddled instructions. One reviewer got an extra job on top (“also switch this Spanish title from vosotros to tú“). It did that, and honestly reported that it had only skimmed German and French. A second, full review of the same post found nine real errors. Lesson: keep each agent’s job clean, and read the reports, not just the numbers.
🧱 The real limit: the quota
Parallelism doesn’t make tokens cheaper; it just gets you to the wall faster. Around lunchtime on day one, after about ten finished posts with four translators and three reviewers in flight, my subscription hit its session limit. All seven agents died in the same second, leaving half-written files behind. Nothing broken online, because nothing is uploaded before it has passed the check and the review. After the reset, the restarted translators were told to reuse a leftover file only after proving it complete against the original, and to rewrite it otherwise. Since then, fewer agents in parallel and a steadier pace. With a usage limit per session and per week, that budget is what decides how fast this goes, not the number of agents.
🚫 What I deliberately didn’t do
- No Chinese (or Japanese, or …). Tempting for the reach, but I’d be publishing text I can’t even spot-check. Three languages I can at least roughly follow felt like the honest limit.
- No fully unattended autopilot. At one point the idea was a systemd timer that starts a headless AI session every few hours and keeps translating while nobody watches. The safety checks in my AI tooling refused to set that up, and in hindsight, rightly so: an unattended AI with SSH write access to a production server is something a human should switch on deliberately, not something that slips in as a convenience. So the pipeline runs while I’m around.
📝 Still on the list
- Finish the remaining posts. ✏️ TODO (Raphael): current state.
- Point internal links and the “Related posts” lists to the translated versions and drop the “(in English)” notes.
- Translate the static pages (About, Imprint, Privacy Policy, AI Usage Notice) and give each language its own menu.
- Look at the search statistics in a few months and see whether anyone actually came.
If you’re reading this in German, French or Spanish: welcome, bienvenue, bienvenido. And if a sentence sounds like it was written by a robot, it’s because it was. Tell me, and a human will fix it.





