I run a small AI song generator in 22 languages, built on Next.js 14 with next-intl. Thirteen more languages are in the queue. Adding one is boring, and it is also easy to get subtly wrong, so I stopped doing it myself.
Now a daily cron job checks a queue. Every three days it starts a headless coding agent (Claude Code), which adds the next language, deploys it, checks it and mails me the result. The first fully unattended run added Norwegian this week.
The agent was the easy part. The work went into deciding what it must check, and what it does when something fails.
What one language actually touches
The Dutch locale was the last one I added in an interactive session. Its commit changed seven files and about 570 lines:
messages/nl.json | 267 +++
scripts/x-post-song.mjs | 2 +-
src/config.ts | 10 +-
src/content/birthdaySong.ts | 199 +++
src/content/songFromLyrics.ts | 95 +++
src/middleware.ts | 2 +-
src/servers/gift.ts | 2 +-
Only the first file is what most people mean by "translation". The middleware matcher, the locale list and the language name maps are each a line that is easy to forget, and each one fails in a different way.
That commit became the spec. The playbook starts by telling the agent to copy what commit 17a27e6 did and do nothing else, then lists every file by name. A real diff is a far better instruction than a description of one, because it shows the exact shape of each change.
The queue is a data file
{
"queue": [
{ "code": "da", "hreflang": "da-DK", "country": "dk", "name": "Dansk", "english": "Danish" },
{ "code": "he", "hreflang": "he-IL", "country": "il", "name": "עברית", "english": "Hebrew", "rtl": true }
],
"done": [
{ "code": "nl", "date": "2026-11-02", "commit": "17a27e6" },
{ "code": "no", "date": "2026-11-05", "commit": "6a4be3a" }
]
}
The order is a business decision: languages come first where people are more likely to pay. The rtl flag sends the agent to one extra step in the layout. The done list doubles as the scheduler's state, so there is no second place to keep in sync.
The cron wrapper stays dumb on purpose. No AI runs unless all of these pass (trimmed, prompt translated):
read -r DAYS LEFT < <(python3 -c "
import json, datetime
q = json.load(open('scripts/locale-queue.json'))
last = max(datetime.date.fromisoformat(d['date']) for d in q['done'])
print((datetime.date.today() - last).days, len(q['queue']))")
[ "$LEFT" -eq 0 ] && exit 0 # queue empty
[ "$DAYS" -lt 3 ] && exit 0 # too soon
pgrep -f "add-locale.md" >/dev/null && exit 0 # already running
timeout 3h claude -p "Follow the steps in scripts/add-locale.md." >> "$LOG" 2>&1
RC=$?
[ $RC -ne 0 ] && mail_me "locale run exited with $RC"
Three days is not a technical limit. It leaves me time to notice a bad language before the next one lands on top of it.
Check translations with code, not with the model
Asking a model whether its own translation is complete tells you very little. A short script does better. It compares every key with en.json and makes sure placeholders survive:
import { readFileSync } from "node:fs";
const flat = (o, p = "") =>
Object.entries(o).flatMap(([k, v]) =>
v && typeof v === "object" ? flat(v, `${p}${k}.`) : [[`${p}${k}`, String(v)]]
);
const load = (l) => new Map(flat(JSON.parse(readFileSync(`messages/${l}.json`, "utf8"))));
const en = load("en");
const target = load(process.argv[2]);
const errors = [];
for (const [key, value] of en) {
if (!target.has(key)) { errors.push(`missing: ${key}`); continue; }
for (const token of ["%s", """]) {
if (value.includes(token) && !target.get(key).includes(token)) {
errors.push(`lost ${token}: ${key}`);
}
}
}
for (const key of target.keys()) if (!en.has(key)) errors.push(`extra: ${key}`);
console.log(errors.length ? errors.join("\n") : `ok: ${en.size} keys`);
process.exit(errors.length ? 1 : 0);
On the real Norwegian file it prints ok: 257 keys. On a copy I broke on purpose:
missing: IndexPage.title
lost %s: IndexPage.myDeleteIn
extra: Extra.x
The second line is the one that matters. A dropped %s is valid JSON and passes a key comparison, and users only see it as a countdown with no number in it.
Don't translate the title
The instruction I care about most is the one that breaks "translate this file": titles and headings must be written the way people in that country search, not converted from English.
| Locale | Home page title |
|---|---|
| en | Free AI Song Generator — Make a Song from Your Lyrics |
| nl | Gratis AI liedje maken — maak een lied van je eigen tekst |
| no | Lag sang med AI gratis — gjør din egen tekst til en sang |
| uk | Створити пісню за допомогою ШІ безкоштовно онлайн |
English leads with the noun "generator". The other three lead with a verb, "make a song with AI", because that is the phrase people type. About three quarters of my Google clicks land on the Ukrainian home page, whose title is written this way.
Two more rules came from things that are invisible in a diff:
- FAQ text must describe the product as it is. A translation can stay accurate word for word and still promise something, so the playbook spells out what is free and what is paid, instead of leaving the agent to infer it.
- Button names quoted in copy must match the real button labels. "Click Make the song" is useless if the button was translated differently in another file.
There is a data trap too. Example songs are looked up by locale and title, so a title reused on two landing pages silently shows the wrong song. The playbook forbids it by name, because neither the build nor the key check would catch it.
"It returned 200" is not a check
An earlier page of mine returned 200 while its content was squashed under the footer. A status code says nothing about what a visitor sees, so after every deploy the agent has to:
- Request the five new pages, plus the Ukrainian home page, which is the one I can least afford to break.
- Confirm that the new language appears in
hreflangand in the sitemap. - Take mobile-width Playwright screenshots of the new pages and actually look at them: not blank, and in the right language.
Decide the failure path before the run
An unattended agent needs to know what "stop" means. The playbook has two cases:
- Before deploy: restore the working tree and mail me the reason. Never deploy half a language.
-
After deploy: if a page is blank or returns 500,
git revert, deploy again, confirm the main page is back, then mail me.
The deploy script is the safety net under both. It builds into a separate directory while the site keeps serving, swaps directories, does a rolling reload, and rolls back to the previous build if the health check fails. A failed build never touches production.
There are also hard limits written down: don't touch secrets, don't change prices or other features, and don't restart the process manager by hand. The agent runs without anyone approving its steps, so the playbook has to carry those limits itself.
What this doesn't solve
- Nobody who speaks Norwegian has reviewed the Norwegian. The checks prove the file is complete and the pages render. They don't prove it reads naturally.
- Costs need their own limit. The first version generated five example songs per language with a paid music API. It only checked the remaining balance, not whether spending it was still a good idea, so I paused that step and the playbook now skips it.
- It's too early to say whether this pays off. One unattended run isn't a track record, and the two newest languages have no meaningful search data yet.
So far the useful lesson is about the shape of the job. Take one real diff, turn it into a named list of steps, check the mechanical parts with code, and write down the failure path before anything runs. You can see the output of a run on the Norwegian birthday song page.
Written with AI assistance. The code and the numbers come from the project's repository.
Top comments (0)