Localization Workflow skill for Codex
Strings in, translations back, no broken placeholders. Codex treats the source locale as the single source of truth, fixes concatenation and plural bugs before strings reach translators, adds context and a glossary, and wires TMS push and pull through bot PRs. A bundled Python checker compares JSON locale files and fails CI on missing keys, empty values, broken ICU or i18next placeholders and plurals without an other branch.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- localization-workflow Installs to ~/.agents/skills/localization-workflow/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install localization-workflow The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project localization-workflow Installs to .agents/skills/localization-workflow/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/localization-workflow.zipunzip -o localization-workflow.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) localization-workflow PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $localization-workflow, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Runs the translation pipeline around an app or docs site - source string hygiene, key naming, translator context, glossaries, TMS sync with Crowdin, Lokalise, Phrase or Weblate, machine-translation drafts with human review, pseudo-localization, and CI checks that catch missing keys and broken ICU or i18next placeholders. Use when setting up or fixing a translation workflow, adding a new language, syncing locale files with a TMS, translating JSON, PO, XLIFF or strings files, or reviewing translations before release.
Use it when
- You are adding a language or connecting a translation management system
- Translated JSON, PO or strings files drift out of sync with the source
- Releases ship with untranslated text or broken {placeholders}
- You want AI-drafted translations without skipping human review
Use something else when
- You need ICU syntax in components, RTL layout or locale routing: use frontend-i18n
- The job is international search targeting and hreflang tags: use hreflang-international-seo
How to check the result
Run scripts/check_locales.py against every shipped locale with zero errors, and confirm a pseudo-locale build shows no hard-coded or truncated strings.
The mistake it prevents
Editing a source string's meaning without changing its key, so every locale keeps the old translation and nobody is told to update it.
Done well means: one source locale is the single source of truth, every string reaches translators with enough context to translate it once, translated files flow back automatically, and CI blocks a release when a locale is missing keys or has a broken placeholder. Nobody hand-edits translated files in the repo without the TMS knowing.
The code side (ICU message syntax in components, RTL layout, locale routing, formatting
dates and numbers) belongs to $frontend-i18n. This skill covers the strings and the
people and tools that move them.
Inspect before changing anything
cat AGENTS.md 2>/dev/null | sed -n '1,80p' # conventions, package manager
ls -R locales/ i18n/ messages/ public/locales/ 2>/dev/null | head -50
ls crowdin.yml crowdin.yaml .phrase.yml lokalise.yml .weblate 2>/dev/null
rg -n "i18next|next-intl|react-intl|formatjs|vue-i18n|gettext|Localizable|xcstrings" \
package.json pyproject.toml *.gradle* 2>/dev/null | head
Establish: the source locale, the file format and layout, the library (decides placeholder syntax), whether a TMS is already connected, and who reviews translations (in-house, agency, community, nobody yet).
Format and layout defaults
| Stack | Format | Layout | Placeholder syntax |
|---|---|---|---|
| i18next / react-i18next | JSON | locales/<lang>/<namespace>.json | {{name}}, plural keys _one, _other |
| next-intl, FormatJS, vue-i18n (ICU mode) | JSON | messages/<lang>.json | ICU {name}, {count, plural, ...} |
| Python, PHP, C (gettext) | PO / POT | locale/<lang>/LC_MESSAGES/<domain>.po | %(name)s, {name}, %s |
| Android | strings.xml | res/values-<lang>/strings.xml | %1$s, <plurals> |
| iOS / macOS (Xcode 15+) | String Catalog .xcstrings | one catalog per target | %@, %lld |
| Enterprise handoff | XLIFF 2.0 / 1.2 | exported per release | format-specific |
Keep the format the stack already uses. Converting formats mid-project loses TMS memory and translator comments.
String hygiene (fix at the source)
Most translation bugs are source-string bugs. Before the first sync, audit the source:
| Problem | Example | Fix |
|---|---|---|
| Concatenation | t('hello') + name + t('welcome') | One message with a placeholder: Hello {name}, welcome |
Plurals by if | count === 1 ? 'file' : 'files' | ICU plural or i18next plural keys; languages have up to 6 forms |
| Reused key across contexts | "open" for a verb button and an adjective status | Separate keys: file.open_action, ticket.status_open |
| Text in images or SVGs | Banner with baked-in text | Move text to strings; overlay with CSS |
| Sentence split around markup | t('click') <a> t('here') | Rich-text message: Click <link>here</link> (Trans component / rich text API) |
| Hard-coded formats | "$" + price, MM/DD | Intl.NumberFormat, Intl.DateTimeFormat, or library formatters |
| Ambiguous short strings | "Post", "Archive" | Add a description for translators (below) |
Key naming: semantic and namespaced by feature (checkout.summary.total_label), never the
English text as the key unless the stack is gettext (where msgid is the English string and
msgctxt disambiguates).
Translator context
Context is the cheapest quality lever. Provide it in the form the TMS reads:
- i18next / ICU JSON: a parallel description file or the TMS’s key comments field. Crowdin
and Lokalise both display key comments; many teams keep
en.context.jsonand upload it. - gettext:
#.extracted comments (# Translators:comments in code are extracted byxgettext --add-comments=Translators:) andmsgctxt. - Android:
<string name="..." description="...">is not standard; use XML comments above the string or the TMS comment field. Mark non-translatable stringstranslatable="false". - Every placeholder: say what it holds (“{name} is the workspace name, max 40 chars”).
- Length limits for buttons, tabs, push notifications. Screenshots attached in the TMS for anything UI-dense.
Glossary and do-not-translate list
Create docs/l10n/glossary.csv (or the TMS glossary) before translating at scale:
term,part_of_speech,definition,de,fr,ja,do_not_translate
Workspace,noun,Top-level container for projects,Workspace,espace de travail,ワークスペース,
Acme Cloud,proper noun,Product name,,,,yes
deploy,verb,Release code to an environment,bereitstellen,déployer,デプロイする,
Product names, plan names, CLI commands, code identifiers and legal entity names are usually do-not-translate. Confirm with the user; marketing teams sometimes localize plan names.
Pipeline
- [ ] 1. Developers add or change source strings only (source locale file)
- [ ] 2. CI on merge: extract/validate source, push sources to the TMS
- [ ] 3. TMS: machine-translation pre-fill (optional), human translation, review step
- [ ] 4. TMS -> repo: pull approved translations into a bot PR (never direct to main)
- [ ] 5. CI on the bot PR: run the locale checker, pseudo-locale smoke test, build
- [ ] 6. Release gate: required locales at 100% approved, or fall back per policy
Decide the fallback policy explicitly: ship with source-language fallback for missing strings (fast, common for SaaS), or block the release until 100% (regulated, contractual).
Crowdin example
# crowdin.yml
project_id_env: CROWDIN_PROJECT_ID
api_token_env: CROWDIN_PERSONAL_TOKEN
preserve_hierarchy: true
files:
- source: /locales/en/**/*.json
translation: /locales/%two_letters_code%/**/%original_file_name%
crowdin upload sources # push changed source strings
crowdin download # pull translations into the paths above
crowdin status # per-language progress
Lokalise (lokalise2 file upload / file download), Phrase Strings (phrase push /
phrase pull with .phrase.yml) and Weblate (git-based, or the wlc client) follow the
same push-sources, pull-translations shape; read the project’s existing config and the
tool’s --help rather than guessing flags. Official GitHub Actions or integrations exist
for each and are the usual way to automate step 4.
All TMS commands need network access and an API token from environment variables. In a Codex sandbox with network off, prepare the config and commands and ask the user to run them or approve network access. Never write a token into a config file.
Uploading translations (as opposed to sources) to a TMS can overwrite reviewed work. Show the user what will be overwritten and get a clear yes first.
Machine and AI-drafted translations
Codex can draft translations, but treat them as pre-fill, not final:
- Translate from the source locale only, never chain (en to de to nl).
- Apply the glossary and do-not-translate list; keep placeholders, tags and ICU syntax byte-identical except for translatable branch text.
- Respect each language’s plural categories: Arabic needs zero/one/two/few/many/other,
Polish one/few/many/other, Japanese only other. Generate the right i18next keys per
locale (
_few,_many) rather than copying English’s two. - Mark output as needing review (the TMS’s “unapproved” state, or a
needs-reviewlabel on the PR). Legal, medical, financial, safety and marketing copy always gets a native human reviewer before release.
Pseudo-localization
Run a pseudo-locale before real translations exist to catch hard-coded strings, truncation and concatenation:
- Accent and expand:
Save changesto[Šåvé çháñgéš ~~~~~~](about 30-40% longer; German and Finnish UI text often runs that long). - Wrap in brackets so clipped text and concatenated fragments are visible.
- Many libraries and TMSs offer a pseudo-locale; otherwise generate
locales/en-XA/from the source with a small script, keeping placeholders untouched.
Validate locale files
Run the bundled checker on JSON locales (ICU, i18next and printf styles are detected):
python3 scripts/check_locales.py locales/en.json locales/de.json locales/fr.json
python3 scripts/check_locales.py --dir locales --source en # en.json or en/*.json layouts
python3 scripts/check_locales.py --dir locales --source en --allow-same '^brand\.' --strict
It reports missing and extra keys, empty values, placeholder mismatches, ICU plurals
without an other branch, markup tag differences and values identical to the source. Exit
code 1 means errors; wire it into CI on the translation PR.
For other formats use the native checker: msgfmt --check -o /dev/null de.po (gettext),
Android lint’s MissingTranslation and StringFormatInvalid, and Xcode’s String Catalog
state column for iOS.
Gotchas
- Changing source text without changing the key silently keeps stale translations. For meaning changes, create a new key or mark the string for re-translation in the TMS.
- Deleting a source key before the TMS sync deletes its translation memory link; remove keys in the same PR that removes their usage, then sync.
- Hand edits to
locales/de/*.jsonin the repo get overwritten on the next pull. Make fixes in the TMS, or configure the TMS to import repo changes. - Locale codes differ by tool (
pt-BR,pt_BR,pt-rBRon Android). Map them once in the TMS config instead of renaming files by hand. - Sorting or reformatting JSON files creates huge diffs on every pull; let the TMS export format win and exclude locale files from formatters if they fight.
Verification
- The checker passes for every shipped locale (or the failures match the agreed fallback).
- Pseudo-locale build shows no unbracketed strings on the main screens.
- A changed source string appears in the TMS after the CI push, and a translation approved in the TMS appears in a bot PR.
- Report: locales and coverage, checker output summary, strings flagged for human review, and any TMS step the user must run with network access.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Localization Workflow"
short_description: "Strings in, translations back, no broken placeholders"
default_prompt: "$localization-workflow Set up Crowdin sync for our locales folder with a CI check for missing keys" scripts/check_locales.py
#!/usr/bin/env python3
"""Compare JSON translation files against a source locale.
Reports missing and extra keys, empty values, placeholder mismatches, ICU plurals
without an `other` branch, markup tag mismatches and values left identical to the
source. Standard library only.
Usage:
python3 check_locales.py SOURCE.json TARGET.json [TARGET.json ...]
python3 check_locales.py --dir locales --source en
(finds locales/en.json + locales/de.json, or locales/en/*.json + locales/de/*.json)
Options:
--style auto|icu|i18next|printf placeholder syntax (default: auto)
--allow-same KEY_PATTERN regex of keys allowed to equal the source (repeatable)
--strict treat warnings as errors
Exit code 0 when no errors, 1 when errors were found, 2 on bad input.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
PLURAL_SUFFIX = re.compile(r"_(zero|one|two|few|many|other)$")
TAG = re.compile(r"</?([A-Za-z0-9]+)[^>]*>")
I18NEXT_VAR = re.compile(r"\{\{\s*-?\s*([\w.]+)[^}]*\}\}")
I18NEXT_NEST = re.compile(r"\$t\(([^)]+)\)")
PRINTF = re.compile(r"%(?:\((\w+)\))?(?:(\d+)\$)?[-+ #0]*\d*(?:\.\d+)?[sdifuxXeEgGc@]")
def flatten(obj, prefix=""):
out = {}
if isinstance(obj, dict):
for k, v in obj.items():
key = f"{prefix}.{k}" if prefix else str(k)
out.update(flatten(v, key))
elif isinstance(obj, list):
for i, v in enumerate(obj):
out.update(flatten(v, f"{prefix}[{i}]"))
else:
out[prefix] = obj
return out
def icu_args(msg: str):
"""Return (argument names, plural/select args missing an `other` branch).
Minimal ICU MessageFormat walker: handles nested plural/select branches and
apostrophe-quoted literals well enough for linting, not for formatting.
"""
names, missing_other = set(), []
i, n = 0, len(msg)
def parse_message(i, depth):
while i < n:
c = msg[i]
if c == "'":
if i + 1 < n and msg[i + 1] == "'":
i += 2
continue
if i + 1 < n and msg[i + 1] in "{}#|":
end = msg.find("'", i + 1)
i = n if end == -1 else end + 1
continue
i += 1
elif c == "{":
i = parse_argument(i + 1, depth)
elif c == "}":
if depth:
return i + 1
i += 1 # stray closing brace at top level: ignore
else:
i += 1
return i
def parse_argument(i, depth):
m = re.match(r"\s*([\w.]+)\s*(?:,\s*(\w+)\s*)?", msg[i:])
if not m:
# not an argument; skip to matching brace
level = 1
while i < n and level:
level += {"{": 1, "}": -1}.get(msg[i], 0)
i += 1
return i
names.add(m.group(1))
kind = m.group(2)
i += m.end()
if i < n and msg[i] == "}":
return i + 1
if kind in ("plural", "select", "selectordinal"):
if i < n and msg[i] == ",":
i += 1
selectors = []
while i < n:
sm = re.match(r"\s*(offset:\d+\s*)?(=?[\w-]+)\s*\{", msg[i:])
if not sm:
break
selectors.append(sm.group(2))
i = parse_message(i + sm.end(), depth + 1)
if "other" not in selectors:
missing_other.append(m.group(1))
while i < n and msg[i] != "}":
i += 1
return i + 1
# simple formatted arg such as {d, date, short}
level = 1
while i < n and level:
level += {"{": 1, "}": -1}.get(msg[i], 0)
i += 1
return i
parse_message(0, 0)
return names, missing_other
def placeholders(value: str, style: str):
if style == "i18next":
return {f"{{{{{m}}}}}" for m in I18NEXT_VAR.findall(value)} | {
f"$t({m})" for m in I18NEXT_NEST.findall(value)
}, []
if style == "printf":
found = set()
for named, pos in PRINTF.findall(value):
found.add(named or pos or "%")
# unnamed %s counts by multiplicity
unnamed = sum(1 for named, pos in PRINTF.findall(value) if not (named or pos))
found.discard("%")
if unnamed:
found.add(f"%x{unnamed}")
return found, []
names, missing = icu_args(value)
return names, missing
def detect_style(values) -> str:
text = "\n".join(v for v in values if isinstance(v, str))
if "{{" in text:
return "i18next"
if PRINTF.search(text) and "{" not in text:
return "printf"
return "icu"
def base_key(key: str, style: str) -> str:
return PLURAL_SUFFIX.sub("", key) if style == "i18next" else key
def compare(src: dict, tgt: dict, style: str, allow_same):
errors, warnings = [], []
src_bases = {base_key(k, style) for k in src}
tgt_bases = {base_key(k, style) for k in tgt}
for b in sorted(src_bases - tgt_bases):
errors.append(f"missing key: {b}")
for b in sorted(tgt_bases - src_bases):
warnings.append(f"extra key (not in source): {b}")
if style == "i18next":
for b in sorted(src_bases & tgt_bases):
src_plural = any(PLURAL_SUFFIX.search(k) and base_key(k, style) == b for k in src)
if src_plural and f"{b}_other" not in tgt:
errors.append(f"plural key missing _other form: {b}")
src_by_base = {}
for k, v in src.items():
src_by_base.setdefault(base_key(k, style), []).append(v)
for key, value in tgt.items():
b = base_key(key, style)
if b not in src_by_base:
continue
if value is None or (isinstance(value, str) and not value.strip()):
errors.append(f"empty value: {key}")
continue
if not isinstance(value, str):
continue
source_val = src.get(key, src_by_base[b][-1])
if not isinstance(source_val, str):
continue
s_ph, _ = placeholders(source_val, style)
t_ph, t_missing_other = placeholders(value, style)
if style == "i18next" and key != b:
# plural forms may legitimately drop {{count}} in e.g. the _one form
s_ph.discard("{{count}}")
t_ph.discard("{{count}}")
if s_ph != t_ph:
lost = sorted(s_ph - t_ph)
added = sorted(t_ph - s_ph)
detail = []
if lost:
detail.append(f"missing {', '.join(lost)}")
if added:
detail.append(f"unexpected {', '.join(added)}")
errors.append(f"placeholder mismatch: {key} ({'; '.join(detail)})")
for arg in t_missing_other:
errors.append(f"ICU plural/select without 'other' branch: {key} ({arg})")
if sorted(TAG.findall(source_val)) != sorted(TAG.findall(value)):
warnings.append(f"markup tags differ: {key}")
if (
value == source_val
and len(re.sub(r"\W", "", value)) > 3
and not any(re.search(p, key) for p in allow_same)
):
warnings.append(f"identical to source (untranslated?): {key}")
return errors, warnings
def load(path: Path) -> dict:
try:
with path.open(encoding="utf-8") as f:
return flatten(json.load(f))
except (OSError, json.JSONDecodeError) as exc:
print(f"error: cannot read {path}: {exc}", file=sys.stderr)
sys.exit(2)
def pairs_from_dir(root: Path, source: str):
if (root / f"{source}.json").is_file():
src = root / f"{source}.json"
return [(src, p) for p in sorted(root.glob("*.json")) if p != src]
src_dir = root / source
if src_dir.is_dir():
out = []
for lang_dir in sorted(d for d in root.iterdir() if d.is_dir() and d != src_dir):
for src in sorted(src_dir.glob("*.json")):
out.append((src, lang_dir / src.name))
return out
print(f"error: no {source}.json or {source}/ directory in {root}", file=sys.stderr)
sys.exit(2)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("files", nargs="*", type=Path)
ap.add_argument("--dir", type=Path)
ap.add_argument("--source")
ap.add_argument("--style", default="auto", choices=["auto", "icu", "i18next", "printf"])
ap.add_argument("--allow-same", action="append", default=[])
ap.add_argument("--strict", action="store_true")
args = ap.parse_args()
if args.dir:
if not args.source:
ap.error("--dir requires --source")
pairs = pairs_from_dir(args.dir, args.source)
elif len(args.files) >= 2:
pairs = [(args.files[0], t) for t in args.files[1:]]
else:
ap.error("give SOURCE and at least one TARGET, or --dir and --source")
total_err = total_warn = 0
for src_path, tgt_path in pairs:
src = load(src_path)
if not tgt_path.is_file():
print(f"\n{tgt_path}: MISSING FILE (source {src_path} has {len(src)} keys)")
total_err += 1
continue
tgt = load(tgt_path)
style = args.style if args.style != "auto" else detect_style(src.values())
errors, warnings = compare(src, tgt, style, args.allow_same)
translated = len({base_key(k, style) for k in tgt} & {base_key(k, style) for k in src})
coverage = 100 * translated / max(1, len({base_key(k, style) for k in src}))
print(f"\n{tgt_path} vs {src_path} [{style}] coverage {coverage:.1f}%: "
f"{len(errors)} error(s), {len(warnings)} warning(s)")
for e in errors:
print(f" ERROR {e}")
for w in warnings:
print(f" warn {w}")
total_err += len(errors)
total_warn += len(warnings)
print(f"\nTotal: {total_err} error(s), {total_warn} warning(s) across {len(pairs)} file pair(s)")
failed = total_err or (args.strict and total_warn)
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.