How to Translate One Blog Post Into 13 Languages With One Agent
A single TypeScript agent calls the Claude API in parallel to translate one EN post into 12 locales in under 90 seconds. Voice preservation requires a two-part system prompt: style constraints first, then locale-specific notes. Cost is roughly $0.004–$0.02 per post on Haiku; skip Sonnet for translation unless tone is mission-critical. My site saw a 34% international traffic lift within 60 days.
Every Wednesday. 28,400+ operators. Zero fluff.
✓ Check your inbox — click the confirmation link to complete sign-up.
✓ You're subscribed!
✓ You're already on the list.
Table of contents
Open Table of contents
Why I built a translation agent instead of hiring translators
I’ll skip the pitch for multilingual SEO — you already know it matters. The problem I had was workflow. Hiring translators per post is expensive ($40–$120/post × 12 locales = $480–$1,440 per article), slow (3–7 day turnaround), and impossible to batch when you have 341 existing posts to catch up on.
The other option people suggest is Google Translate or DeepL. Both are fine for accuracy but they destroy voice. My writing style is direct, first-person, and slightly contrarian. Machine translation tends to make everything sound formal and passive. That’s a problem when voice consistency is part of your brand.
So I built a Claude-backed TypeScript agent. It runs in CI on every merge to main, fans out translations in parallel, writes files back to disk, and skips any locale that already has a file. The whole thing takes under 90 seconds for a new post.
The project structure
The agent lives in scripts/agent/translate-worker.ts. It’s called from a top-level orchestrator that reads the EN post, extracts frontmatter, and dispatches one translation job per locale.
scripts/
agent/
translate-worker.ts # per-locale translation logic
translate-all.ts # orchestrator: reads EN, fans out to 12 locales
lib/
frontmatter.ts # parse/serialize gray-matter frontmatter
voice-prompt.ts # shared system prompt builderThe orchestrator (translate-all.ts) uses Promise.allSettled so a single failed locale doesn’t block the rest.
The system prompt engineering
This is where most people get it wrong. They write a one-liner like “translate this to French, keep the author’s voice.” That produces mediocre output.
My system prompt has two mandatory sections:
Section 1 — Style constraints (universal, prepended to every call):
// scripts/agent/lib/voice-prompt.ts
export function buildSystemPrompt(targetLocale: string): string {
const styleConstraints = `
You are a professional translator working on blog posts written by Alejandro Rioja.
STYLE RULES — apply to every locale:
- Short paragraphs (1–3 sentences max). Do not merge them.
- First-person, direct voice. Never passive if active is natural.
- No filler phrases: no "In today's world", no "It is worth noting that".
- Preserve all markdown: headings, bold, italics, code blocks, links.
- Translate heading text but keep the ## / ### prefix exactly.
- Code blocks: translate comments only. Keep all variable names, strings, and syntax in English.
- Preserve frontmatter keys exactly. Only translate the VALUES for: title, ogTitle, description, tldr, imageAlt.
- Keep these frontmatter values UNCHANGED: pubDate, updatedDate, translation_key, tags, image, author, draft, lang (set lang to: ${targetLocale}).
`.trim();Section 2 — Locale-specific notes (appended per call):
const localeNotes: Record<string, string> = {
ar: "Arabic: use Modern Standard Arabic (MSA). RTL layout is handled by the CMS — do not add any RTL markup. Avoid overly formal Classical Arabic registers.",
de: "German: use informal 'du' not formal 'Sie'. Compound nouns are fine; don't over-hyphenate. Keep tech terms in English when that's the industry standard (e.g. 'Content Marketing', 'SEO').",
es: "Spanish: use neutral Latin American Spanish, not Castilian. Tuteo ('tú') over 'usted'. Keep anglicisms that are standard in tech (SEO, agente, prompt).",
fr: "French: use informal 'tu'. Avoid over-formalizing. Tech anglicisms are acceptable when widely used (SEO, agent, prompt).",
hi: "Hindi: use Devanagari script. Mix Hindi and English naturally for tech terms — this is standard in Indian tech writing. Don't force Hindi equivalents for words like 'agent', 'prompt', 'SEO'.",
it: "Italian: use 'tu' form. Keep English tech terms where they're standard in Italian digital marketing.",
ja: "Japanese: use です/ます (polite) style, not casual or keigo. Keep technical English terms in katakana where standard (e.g. エージェント, プロンプト, SEO).",
ko: "Korean: use 합쇼체 (formal polite). Tech terms in English or standard Korean loanwords. Keep SEO, agent, prompt as-is or standard loanwords.",
nl: "Dutch: use 'je/jij' (informal). Keep English tech terms standard in Dutch digital marketing.",
pt: "Portuguese: use Brazilian Portuguese (pt-BR). Informal 'você'. Keep tech anglicisms standard in Brazilian digital marketing.",
ru: "Russian: use modern, accessible Russian. Avoid overly bureaucratic phrasing. Tech terms can stay in English where that's the norm in Russian tech writing.",
zh: "Chinese: use Simplified Chinese (zh-CN). Modern, accessible tone. Tech terms can use standard Chinese equivalents or keep English where that's industry norm.",
};
return `${styleConstraints}\n\nLOCALE-SPECIFIC NOTES for ${targetLocale}:\n${localeNotes[targetLocale]}`;
}The translate worker
Here’s the full worker. It reads the EN file, calls Claude, writes the output to disk.
// scripts/agent/translate-worker.ts
import Anthropic from "@anthropic-ai/sdk";
import * as fs from "fs";
import * as path from "path";
import { buildSystemPrompt } from "./lib/voice-prompt";
const client = new Anthropic();
export interface TranslateJob {
enFilePath: string;
locale: string;
outputDir: string;
model?: "claude-haiku-4-5" | "claude-sonnet-4-5";
dryRun?: boolean;
}
export async function translatePost(job: TranslateJob): Promise<string> {
const { enFilePath, locale, outputDir, model = "claude-haiku-4-5", dryRun = false } = job;
// Idempotency: skip if translation already exists
const filename = path.basename(enFilePath);
const outPath = path.join(outputDir, locale, filename);
if (fs.existsSync(outPath)) {
console.log(`[${locale}] Already exists — skipping: ${outPath}`);
return outPath;
}
const enContent = fs.readFileSync(enFilePath, "utf-8");
const systemPrompt = buildSystemPrompt(locale);
const message = await client.messages.create({
model,
max_tokens: 8192,
system: systemPrompt,
messages: [
{
role: "user",
content: `Translate the following blog post to ${locale}. Return ONLY the translated markdown file content — no explanation, no preamble, no code fences around the whole file.\n\n${enContent}`,
},
],
});
const translated = (message.content[0] as { type: string; text: string }).text;
if (!dryRun) {
fs.mkdirSync(path.join(outputDir, locale), { recursive: true });
fs.writeFileSync(outPath, translated, "utf-8");
console.log(`[${locale}] Written: ${outPath}`);
}
return outPath;
}The orchestrator
// scripts/agent/translate-all.ts
import * as path from "path";
import * as fs from "fs";
import { translatePost } from "./translate-worker";
const LOCALES = ["ar", "de", "es", "fr", "hi", "it", "ja", "ko", "nl", "pt", "ru", "zh"];
const POSTS_DIR = path.resolve("src/content/posts");
const MODEL = (process.env.TRANSLATE_MODEL as "claude-haiku-4-5" | "claude-sonnet-4-5") ?? "claude-haiku-4-5";
async function main() {
// Accept a specific file or translate all EN posts
const targetFile = process.argv[2];
const enFiles = targetFile
? [path.resolve(targetFile)]
: fs.readdirSync(path.join(POSTS_DIR, "en")).map((f) => path.join(POSTS_DIR, "en", f));
console.log(`Translating ${enFiles.length} post(s) × ${LOCALES.length} locales. Model: ${MODEL}`);
for (const enFile of enFiles) {
const results = await Promise.allSettled(
LOCALES.map((locale) =>
translatePost({
enFilePath: enFile,
locale,
outputDir: POSTS_DIR,
model: MODEL,
})
)
);
results.forEach((r, i) => {
if (r.status === "rejected") {
console.error(`[${LOCALES[i]}] FAILED:`, r.reason);
}
});
}
console.log("Done.");
}
main();Run it with:
# Translate one new post
npx ts-node scripts/agent/translate-all.ts src/content/posts/en/my-new-post.md
# Translate everything (idempotent — skips existing)
npx ts-node scripts/agent/translate-all.tsCost breakdown: Haiku vs Sonnet
Here’s what it actually costs per post, based on my usage:
| Model | Input tokens (avg) | Output tokens (avg) | Cost per locale | Cost × 12 locales |
|---|---|---|---|---|
| claude-haiku-4-5 | ~2,400 | ~2,600 | ~$0.0004 | ~$0.005 |
| claude-sonnet-4-5 | ~2,400 | ~2,600 | ~$0.015 | ~$0.18 |
For 341 posts × 12 locales on Haiku: roughly $1.70 total. That’s the entire backlog.
Sonnet produces marginally better idiomatic phrasing but for most posts the difference isn’t worth 36× the price. I use Sonnet only for posts where nuanced persuasive tone matters — like sales pages or high-traffic cornerstone content.
You can switch models per-run with the TRANSLATE_MODEL env var:
TRANSLATE_MODEL=claude-sonnet-4-5 npx ts-node scripts/agent/translate-all.ts src/content/posts/en/flagship-post.mdReal results: what happened to my traffic
I shipped the full backlog translation (341 posts) in December 2025. Within 60 days:
- +34% organic sessions site-wide (Google Search Console, Jan–Feb 2026 vs Oct–Nov 2025)
- Top new locale by sessions: Brazilian Portuguese (pt) — 11% of new international traffic
- Top new locale by conversion rate: German (de) — 2.1% consultation booking rate vs 1.8% global average
- Worst performer: Arabic (ar) — traffic came in but zero conversions. I suspect the booking flow isn’t localized beyond the post content.
- Japanese (ja) and Korean (ko): meaningful traffic lift (8% and 6% of international sessions respectively) with above-average engagement (time-on-page up 40% vs EN baseline)
The Japanese and Korean results surprised me. Both locales have high-quality AI-adjacent communities and apparently decent appetite for practical operator content.
The operator’s bottom line
One agent, one hour of setup, $1.70 in API costs. That’s what it took to make 341 posts discoverable in 12 additional languages. The SEO lift alone paid for the compute in the first week. If you’re running a content-heavy site and you haven’t built this yet, you’re leaving international traffic on the table. The code above is the full implementation — fork it, swap in your voice-prompt notes, and run it against your backlog tonight.
Every Wednesday. 28,400+ operators. Zero fluff.
✓ Check your inbox — click the confirmation link to complete sign-up.
✓ You're subscribed!
✓ You're already on the list.
Get the AI playbook in your inbox
Every Wednesday. 28,400+ operators. Zero fluff.
Check your inbox.
We sent you a confirmation email — click the link inside to complete your subscription. Check spam if you don't see it within a minute.
You're subscribed.
Welcome — the next edition lands in your inbox soon.
You're already on the list — look for it every Wednesday.