The situation
More and more of your customers would rather read about you in their own language. Translating the site by hand means copying every page, post and FAQ into the new language, then the header, footer and cookie banner, one at a time, and keeping up as the originals change. One command does all of it: it adds the language to your site, copies everything into it, translates the words and leaves each one as a draft with a preview link. Your links, images and business name stay as they are. You read the drafts, publish them, and put the language live when you’re happy.What you’ll build
An Ace command,node ace site:translate fr "Français", in your own AdonisJS app. For each page, blog
post and FAQ it copies the document into the new language with the Fetchski Attract SDK, then hands
its words to an agent built with OpenRouter’s Agent SDK (@openrouter/agent). The agent has one
tool, save_translation, which checks the translation and writes it to the language’s draft only
once it passes. Your header, footer and cookie banner get the same treatment: the site names which
of its settings hold those words, the agent translates just those, and your code points each of
their links at the page in the new language. Your code, not the agent, chooses what to translate,
copies it, sends it for review and makes the preview links. Nothing goes live until a person
publishes it.
Only what changed
Links, images and names kept
{year} the site fills in, or hands a sentence back untranslated.Drafts, never live
--publish.Spend capped and reported
- A Fetchski Attract site you can sign in to as someone who may manage API keys and languages.
- Node.js 26.12.1 or later (
node --version). This page was built with AdonisJS 7.6,@openrouter/agent1.1 and Zod 4.6. - An OpenRouter account with credit and an API key (openrouter.ai/settings/keys).
The agent runs on
~anthropic/claude-sonnet-latest, OpenRouter’s name for the newest Claude Sonnet, so it moves to each new Sonnet by itself. When you make the key, give it a credit limit (for example $20), so nothing can spend more. - About 30 minutes to set it up.
Make the API key
The app gets its own key, with only what the translation needs. It can add a language and write drafts, but it can’t publish a page, a post or an FAQ.Open API Keys
https://acme.example/cms-login. Press ⌘K
(Ctrl+K on Windows and Linux), type API Keys and press Enter.
Click Create API Key.Name it
Translate the site.Pick the Site translation pack
content:pages:publish (and its blog and FAQ twins)
the key can’t take a translation live, and without global:publish it can’t take the new
header and footer live either. global:write could change any of your site’s settings: the
command writes only the words and links each language keeps its own copy of.Check Granted before you create the key: it should list exactly the 13 scopes above.
Your site’s packs can be edited, so if it shows any other scope (a publish or delete one, say),
take it out with Edit scopes, or have someone who manages API keys choose Restore default
packs in Scope Packs.No Site translation pack in the list? Someone who manages API keys restores it: ⌘K, Scope
Packs, Restore default packs. Or choose Pick scopes one by one and tick the thirteen
scopes above.Pick the signing mode
Create it and copy both values
fsk_acme_…) and
the Signing secret (fss_…). They are shown once: keep them for steps 2 and 4 below.Step by step
Create the app
Your AdonisJS project has been created successfully!, then a prompt inside
site-translate.Point @fetchski at your site
site-translate, with the Secret token you copied above.Your own site serves the @fetchski packages, and any API key from it lets you in. In your
project’s folder (beside its package.json), make a file named .npmrc holding these two lines:@fetchski packages come from your site; the second is the key to sign in with.
Put your site’s address in both lines (set your site in the bar under this page’s title and the
example shows it), and paste your whole key in place of fsk_acme_…. Write the key itself, never a
variable or a value from .env: npm and pnpm don’t read .env, and pnpm refuses a variable here..gitignore (make the file if
there isn’t one):@fetchski/… package, with
your key.You should see: .npmrc beside package.json, and .npmrc as the last line of .gitignore.Install the four packages
.npmrc, run the line for the package manager your
project uses:@fetchski/attract-cms-sdk calls your site and @fetchski/attract-cms-adonis fits it into
AdonisJS; @openrouter/agent runs the agent and its tool, and zod describes the tool’s input.You should see: added … packages.Point the app at your site
.env, the
rules that check them to start/env.ts, and config/attract.ts, and registers the provider that
puts the site’s client in the container. A mistyped key or address writes nothing.You should see: DONE: lines for config/attract.ts, .env, start/env.ts and adonisrc.ts,
then [ success ] @fetchski/attract-cms-adonis is configured. Next: node ace attract:check. Run
that: it ends [ success ] The site acme is ready. The Key accepted row reads warn, the key works but may not list pages: that’s expected, the key reads pages through their drafts.Add the model's environment variables
.env and add these lines at the end, with your OpenRouter key:start/env.ts with this whole file:.env.test with this whole file:configure added, and the two OpenRouter
ones. The app refuses to boot when a value is missing, and the keys stay Secrets, which print as
[redacted] in logs. Tests never call the site or the model, so placeholders do there.You should see: nothing yet: the next steps use them.Gather the model's options
Put the model in the container
OpenRouter is a class, so it is its own container key, as the package made SiteClient:
@inject() hands both to the service, and a test replaces each with a fake.You should see: DONE: create providers/openrouter_provider.ts and DONE: update adonisrc.ts file.List the names never translated
Reach any document on the site
app/services/site_documents.ts:site.request() calls those routes by
name, with the key, its signature, retries and the SDK’s errors, as every SDK method does.Only the words
wordingOf() keeps titles, headings, text, rich text and button labels, keyed as the site’s
PATCH writes them back, so a write changes the words and nothing else.What each page shows
relatedTo() finds the images, authors and categories a document shows. They are copied
into the language first: a page keeps a related document only once it exists there.Only the settings' own words
wordingOf() takes only the fields the site names with
getGlobalTranslatedFields(); every other setting is shared by all your languages.Links to your pages
linksTo() points each header and footer link to one of your pages at that page in the new
language, keeping its #anchor. A link to another site stays as it is.Give the agent its tool
app/services/translation_tools.ts. It is the file pinned beside this page; here it is
again in full:save_translation writes
nothing until every key is there, translated, and every tag, link, glossary name and
{year}-style word the site fills in is kept (a sentence handed back as it was counts as
untranslated; a name or an address never does);
otherwise it
sends the problems back, and the agent fixes them and tries again.Write the translator
What needs translating
Copy
Translate
callModel() per document, stopped by 6 steps, $0.50 or the save. Each call may take
up to timeoutSeconds (ten minutes: room for a long page on a slow model, so it only ends a
call that hangs); a call the model doesn’t answer is tried again, up to attempts, rather
than stopping the run. Each saved draft is sent for review and, for pages and posts, shared
for its preview link.Add the command
startApp: true boots the app, so the container has both clients; @inject() on run()
hands it the SiteTranslator. Any document left untranslated sets exit code 1, so a script
around it can tell.You should see: node ace site:translate --help prints the description, <code>, <name>
and --publish.Format, lint and type-check
configure and make:provider write adonisrc.ts in a shape Prettier changes, and
codegen writes the index files tsc reads.You should see: npm run lint and npm run typecheck print only their own command lines
(> eslint ., > tsc --noEmit), no errors.Run it
Translate
Read the drafts
/about-us becomes /fr/about-us); links to other sites stay as they are.Publish them
Submitted Drafts and press Enter. Open each
translation, change anything you like, and click Publish. Not happy with one? Dismiss it
there: it stays an unpublished draft.Put the language live
Keep it current
If something goes wrong
… isn't a language code
… isn't a language code
fr, or a language and a country, such as pt-br. Put the name in
quotes when it has a space: "Português (Brasil)".A message naming a scope
A message naming a scope
content:…:write too), then run the command again.Site … answered PATCH /api/v1/site-languages/… with 400 invalid
Site … answered PATCH /api/v1/site-languages/… with 400 invalid
“en” is the main language’s code: that code is your main language’s. Give the new language its own.“/api” is already one of the site’s own addresses: some codes are taken by your site’s own addresses. Choose a different code.
Not saved: … in the table
Not saved: … in the table
Not saved:
are the problems its last check found. Its draft is the untouched copy. Run the command again to
retry just that one; if a name keeps changing, add it to storage/glossary.txt.Not translated: the run reached $10
Not translated: the run reached $10
maxCostPerRunUsd in config/openrouter.ts.Not translated: the model didn't answer (…)
Not translated: the model didn't answer (…)
timeoutSeconds in
config/openrouter.ts.EnvValidationException: Missing environment variable
EnvValidationException: Missing environment variable
Missing environment variable "OPENROUTER_API_KEY" (or another name): that line isn’t in .env.
Add it as in step 5 of Step by step. In tests, it’s missing from .env.test.LOG_LEVEL=debug in front:
LOG_LEVEL=debug node ace site:translate fr "Français".
Test it
The tests fake both sides. A fake site keeps its documents in memory and answers the routes the translator calls, on top offakeAttract(), which answers every SDK method and records it. For the
model, a tiny server on 127.0.0.1 answers OpenRouter’s requests in its own stream format: so the
real agent loop runs, with the real tool and its checks, and nothing is sent or spent.
The fake model
serverURL points a real OpenRouter client at it, so the test swaps in that client
(Japa’s swap(), restored after the test) and nothing else changes. fakeTranslator “translates”
by putting [fr] in front of the words, so a test can see what was written.The fake site
The wording's tests
The translator's tests
The command's tests
ace.ui.switchMode('raw') keeps the command’s log lines, so the test can assert them.
Commands live outside app/, so the test imports this one by path.Run them
Turn it off
Revoke the site key
API Keys, open Translate the site, and switch Enabled off (or
delete it). The app’s next call is refused.Revoke the OpenRouter key
.env.Take the language down
Languages, set its Public to No (visitors stop seeing it) or remove it. Its
translations are kept: add the same code again and they come back.Nothing runs on its own
Related
Your site in more than one language
Update the site's languages
Testing in AdonisJS
fakeAttract() and the assertions