Translation is a job people already hand to coding agents. The Claude Code overview page uses it as a scripting example: claude -p "translate new strings into French and raise a PR for review".

The pull request comes back and you open the diff. It is a wall of French. Every line looks plausible, and you can't tell whether {count} survived in all of them, because you don't read French well enough to spot the one string where the agent translated the placeholder name too. Now do that for German, Japanese and Polish.

A short script catches what that diff hides. This post sets up four agents, one per locale, each on its own branch, and gives them a check that fails on exactly those mistakes.

Why translation splits cleanly

Most tasks you give parallel agents overlap somewhere: a shared type, a config file, a test helper. Translation is the rare one that doesn't, if you keep one file per locale:

locales/ en.json <- source, written by you de.json es.json ja.json pl.json 

The German agent writes de.json and nothing else. The Polish agent writes pl.json and nothing else. Four branches that each touch a different file merge without a conflict, in any order. The only shared file is en.json, and nobody but you edits it.

The source file uses ICU MessageFormat, the syntax the FormatJS libraries read. Keep it flat, one key per string:

{ "cart.title": "Your cart", "cart.greeting": "Welcome back, {name}", "cart.items": "{count, plural, one {# item} other {# items}}", "checkout.total": "Total: {total, number}" } 

{name} and {count} are arguments your code passes in. The FormatJS docs are blunt about them: "A value must be supplied for every argument in the message pattern…", and missing values can cause a runtime formatting error. If the Polish agent renames {count} to {liczba}, your code still passes count, and the Polish cart breaks while English works.

Four parallel rails leaving one station and rejoining at another, each carrying one sealed envelope, with no crossings
One source file, four branches that never touch each other's files, one merge point.

Step 1: one brief, four tasks

Give every agent the same brief, with only the locale code changed:

Translate locales/en.json into <LOCALE>. Write only locales/<LOCALE>.json. Edit no other file. Keep every key. Add no keys. Keep every {placeholder} name exactly as in English. Keep plural, select and number arguments the same type. Give each plural every category Intl.PluralRules has for <LOCALE>. Done when `node scripts/check-locales.mjs <LOCALE>` exits 0. Commit with the message "i18n: <LOCALE>". 

The brief names the check, so the agent can run it before it says it's finished.

With plain git, each agent gets its own worktree: a second working folder on its own new branch, sharing one repository.

for lc in de es ja pl; do git worktree add -b "i18n/$lc" "../app-$lc" done 

Start an agent in each folder, paste the brief, and keep four terminals in view.

I build Hermes IDE, so weigh this part with that in mind. In Hermes, ⌘N (Ctrl+Shift+N on Windows and Linux) starts a task, and each task gets its own hermes/<name> branch in its own worktree, with the brief as the agent's first prompt (README). You pick the agent, model and effort per task, so German and Spanish can go to Claude Code and Japanese and Polish to Codex. Each of the four sessions shows one status, and ⌘I jumps to the agent that has waited longest for you.

The Hermes New task sheet with a task typed, chips for Claude Code, the project, a new hermes/ worktree, Accept edits, model and effort, and the command preview
The ⌘N New task sheet: chips for Claude Code, the orders-api project, a new hermes/ worktree, Accept edits, model and effort, and the "Hermes will run" preview of the claude command.

New Hermes worktrees get their dependencies in seconds (README), so the check script can run inside every one of them. The per-repository setup is described in the worktree recipes doc.

The Hermes Review Desk Worktrees tab listing one Hermes worktree each for three projects, with branch, task, age and disk size
The Review Desk's Worktrees tab: one Hermes worktree each for orders-api, web-app and orders-types under ~/code, with each worktree's hermes/ branch, task, age and disk size, and the free disk space.

Step 2: the check script

The script compares every locale against en.json and fails on four kinds of mistake: a missing or empty key, an extra key, a placeholder that was renamed, dropped or changed type, and a plural that lacks a category the locale needs. It uses FormatJS's own parser, @formatjs/icu-messageformat-parser (version 3.5.22 today):

npm install --save-dev @formatjs/icu-messageformat-parser 
// scripts/check-locales.mjs // usage: node scripts/check-locales.mjs [locale ...] import { readFileSync, readdirSync } from "node:fs"; import { parse, TYPE } from "@formatjs/icu-messageformat-parser"; import { isStructurallySame } from "@formatjs/icu-messageformat-parser/manipulator.js"; const DIR = "locales"; const SOURCE = "en"; const load = (lc) => JSON.parse(readFileSync(`${DIR}/${lc}.json`, "utf8")); const source = load(SOURCE); const args = process.argv.slice(2); const locales = args.length ? args : readdirSync(DIR) .filter((f) => f.endsWith(".json")) .map((f) => f.slice(0, -".json".length)) .filter((lc) => lc !== SOURCE); let problems = 0; const fail = (lc, key, msg) => { problems++; console.error(`${lc} ${key}: ${msg}`); }; // every plural, including ones nested in select or tags function plurals(ast, found = []) { for (const el of ast) { if (el.type === TYPE.plural) found.push(el); for (const opt of Object.values(el.options ?? {})) { plurals(opt.value, found); } if (el.children) plurals(el.children, found); } return found; } for (const lc of locales) { const target = load(lc); for (const key of Object.keys(target)) { if (!(key in source)) fail(lc, key, "not in en.json"); } for (const [key, text] of Object.entries(source)) { const t = target[key]; if (typeof t !== "string" || !t.trim()) { fail(lc, key, "missing or empty"); continue; } try { const ast = parse(t); const same = isStructurallySame(parse(text), ast); if (!same.success) fail(lc, key, same.error?.message); for (const p of plurals(ast)) { const type = p.pluralType; const { pluralCategories } = new Intl.PluralRules(lc, { type }).resolvedOptions(); for (const cat of pluralCategories) { if (!(cat in p.options)) { fail(lc, key, `{${p.value}} lacks "${cat}"`); } } } } catch (err) { fail(lc, key, err.message); } } } console.error(problems ? `${problems} problem(s)` : "locales OK"); process.exit(problems ? 1 : 0); 

The script leans on three library calls, each documented in its source:

  • parse() throws a SyntaxError on broken ICU, such as an unclosed brace, and by default it also rejects a plural with no other branch (index.ts).
  • isStructurallySame() collects the variables in both messages and fails on a different count, a missing name or a changed type (manipulator.ts). If the agent writes {total} where English had {total, number}, that is a type change, and it fails.
  • Intl.PluralRules(...).resolvedOptions().pluralCategories lists the categories a locale uses, straight from the runtime (MDN).

That last one is the check a reviewer almost never does by eye. In CLDR's plural rules, English and German have one and other. Japanese has only other. Polish has one, few, many and other. Spanish has one, many and other, where many covers round millions. So this Polish line looks fine in a diff and fails twice:

"cart.items": "{liczba, plural, one {# produkt} other {# produktów}}" 

The variable was renamed, so isStructurallySame reports that count is missing. And the plural has no few or many, so "2 produktów" would show where Polish wants "2 produkty". The passing message, split over lines here for reading (in pl.json it is one string):

{count, plural, one {# produkt} few {# produkty} many {# produktów} other {# produktu}} 

The plural rule is strict on purpose: it makes the Spanish agent write a many branch too. If your counts never reach a million, you can drop that requirement for es; make that change in the script so the exception is written down.

A missing locale file makes readFileSync throw, and Node exits non-zero, which still counts as a failure.

Step 3: make the script the "done" check

The brief tells the agent to run the check. A check that runs whether or not the agent remembers is better.

In Hermes, your own checks can decide when a task is done (README). Give each task this check, with that task's locale:

node scripts/check-locales.mjs pl 

Pass the locale. Run with no argument and the script checks every locale file in the folder. Inside the Polish worktree, the German, Spanish and Japanese files are not translated yet, so that run would fail for the wrong reason.

When the check fails, paste its output back to the agent. Each line names the locale, the key and the problem, which is a precise enough brief for a second pass.

Without Hermes, the same script works as a pre-commit hook or a CI job. Either way, wire it so a red result blocks the merge.

Step 4: land the four branches one after another

Before you merge a branch, confirm it kept to its file. The three-dot form lists only what the branch changed since it split from main:

git diff --name-only main...i18n/pl 

One line, locales/pl.json, is the answer you want. Anything else means the agent edited a shared file, and that is where a conflict would come from.

Then land them in sequence:

git switch main for lc in de es ja pl; do git merge --no-edit "i18n/$lc" || break done node scripts/check-locales.mjs 

The first merge can fast-forward. The other three make merge commits, and none conflict, because each brings one file nobody else touched. The || break stops the loop if one does. The last line runs the check across every locale on main. That run matters if en.json gained a key while the agents worked: each branch passed against its own older copy, and only main has the new key.

In Hermes, Land commits, opens a pull request or merges locally into the branch the task started from, and it can be undone (README). Land the four tasks one by one, then run the full check on main once.

What the script can't tell you

It checks structure, not meaning. A German string can pass every rule and still be stiff, too formal for your product, or twice as long as the button it sits in. It can't tell a good Japanese translation from a bad one with the same placeholders.

Translation platforms automate the same layer. Crowdin's QA checks run "Placeholders mismatch" and "ICU syntax" automatically and flag the string for the translator. Let the script take the mechanical part, so whoever reads the four diffs, you or a native speaker, spends their attention on wording.

Two extensions are worth adding once the basics hold: fail when a translated string is identical to English (with an allow-list for "OK" and brand names), and flag strings more than twice the English length for UI review.

If this was useful

The check in this post treats agent output the way you should treat any model output: parse it, compare it with what you expected, and fail loudly. AI That Answers is the first book in my AI in TypeScript series, and it covers that habit from the first LLM call on: prompts, structured output you validate, and what each call costs. If your next step is a translate endpoint in your own Node app instead of an agent in a terminal, start there.

AI That Answers — AI in TypeScript

The whole series, from your first LLM call to agents in production, is at xgabriel.com/ai-in-typescript.

If you want the four agents in one window, Hermes IDE is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It is not affiliated with Nous Research or its Hermes Agent. The code is on GitHub.