Skip to main content

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

Run it again any time. A translation is redone only when its original changed since it was copied, so a second run on an unchanged site makes no model calls and costs nothing.

Links, images and names kept

The agent sees only the words. Every link, image, color and setting stays as it was, and a check refuses a translation that changes an HTML tag, a link, a name from your glossary or a {year} the site fills in, or hands a sentence back untranslated.

Drafts, never live

Every translation is a draft in Submitted Drafts. The new language stays private, so only people signed in to the CMS see it, until you run the command with --publish.

Spend capped and reported

Every run prints its tokens and cost. One document stops at $0.50, and a run starts no new document past $10.
Before you start, you need:
  • 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/agent 1.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

Sign in to your site’s CMS at 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

Label: Translate the site.

Pick the Site translation pack

Under Scopes, keep Start from a pack and pick the Site translation pack. It ticks these thirteen scopes:Add nothing else. Without 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

Request signing: Token + signature on writes. The SDK signs each write for you with the key’s signing secret.

Create it and copy both values

Click Create key. Under Your API key is ready, copy the Secret token (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

Terminal
Why: the API starter kit is the smallest AdonisJS 7 app with Ace, Japa tests, ESLint and Prettier set up. It starts on SQLite, which this Recipe doesn’t use.You should see: Your AdonisJS project has been created successfully!, then a prompt inside site-translate.

Point @fetchski at your site

Do this in 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:
.npmrc
The first line says @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.
Any API key from your site works, the same one your app uses included: its scopes and signing mode don’t matter for installing, only that it’s switched on and not past its expiry date. No key yet? Make one in the CMS under API Keys (Authentication).
Then keep the file out of git. Add this line to the project’s .gitignore (make the file if there isn’t one):
.gitignore
.npmrc now holds a key to your site: never commit it. If git already tracks a .npmrc, stop tracking it with git rm --cached .npmrc. Keep these lines in this project’s .npmrc only, never in the .npmrc in your home folder. In CI, write the same file from a secret before installing.
Why: your site serves the Fetchski SDK, so npm asks it for every @fetchski/… package, with your key.You should see: .npmrc beside package.json, and .npmrc as the last line of .gitignore.

Install the four packages

In a terminal, in the same folder as the .npmrc, run the line for the package manager your project uses:
401 Unauthorized? Your site refused the key in .npmrc: it’s mistyped or cut short, switched off, or past its expiry date. Check too that .npmrc sits in the folder you ran the command in.
Why: @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

Terminal
It asks for your site’s address, then the Secret token and the Signing secret you copied (both hidden as you type).Why: it reads your site’s slug from the key, writes the address and both values to .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

Open .env and add these lines at the end, with your OpenRouter key:
.env
Replace start/env.ts with this whole file:
start/env.ts
And replace .env.test with this whole file:
.env.test
Why: the whole file is the starter kit’s, the rules 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

Create the file:
config/openrouter.ts
Why: the three numbers cap every run, whatever the model does. A document needs one or two steps: one to save, and one more when the check names a problem to fix.

Put the model in the container

Terminal
Then replace the file it made:
providers/openrouter_provider.ts
Why: 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

Create the file, with your own names:
storage/glossary.txt
Why: the agent gets this list with every document, and the check refuses a translation that loses one of these names. Add a name whenever a translation changes one.

Reach any document on the site

Create app/services/site_documents.ts:
app/services/site_documents.ts
Why: a page shows documents of many types (images, authors, categories, forms), and the site copies each into a language through the same routes. 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

For the site settings, 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

Create app/services/translation_tools.ts. It is the file pinned beside this page; here it is again in full:
app/services/translation_tools.ts
Why: the check runs in your code, not in the model’s judgment. 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

app/services/site_translator.ts
Why: your code does the whole run in three phases, and the agent only translates.

What needs translating

Every page (parents first), post and FAQ, and the site settings, compared with its copy: a translation is up to date when it was copied from the current original and written since.

Copy

What any of them shows first, by the same rule (so a copy a stopped run left is translated by the next run), then each one, over an older copy. A key missing a scope stops the run here, before anything is overwritten. The site settings’ links to your pages are then pointed at the copies.

Translate

One 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

Terminal
Replace the file it made:
commands/site_translate.ts
Why: 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

Terminal
Why: 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

Give the language’s code and its name as its speakers write it:
Terminal
You should see, after a few seconds per document (the cost and tokens are the model’s, so yours will read differently):
Terminal
Pages and blog posts get a preview link. FAQs and what the pages show have no page of their own: they are in Submitted Drafts.

Read the drafts

Open a Preview link: it is the translated page as visitors will see it, with no sign-in, even while the language is private. Send it only to the people reviewing it.Your header, footer and cookie banner are one draft, Header, footer and cookie banner in Submitted Drafts. Their links to your pages already point at the pages in the new language (/about-us becomes /fr/about-us); links to other sites stay as they are.

Publish them

Sign in to the CMS, press ⌘K, type 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

Terminal
It makes the language public, translates anything new or changed (as drafts, as always), and ends:
Terminal
Visitors now get a language switcher in your site’s header. A page you haven’t published in French isn’t there in French.

Keep it current

Run the same command after you change your site. Only what changed is translated again, and its new draft goes back to Submitted Drafts.

If something goes wrong

Use a lowercase code, such as fr, or a language and a country, such as pt-br. Put the name in quotes when it has a space: "Português (Brasil)".
The key lacks that scope: the run stopped before it overwrote anything. Add the scope to the key under API Keys (a page that shows a kind of document missing from the table above, such as a team member, needs that kind’s content:…:write too), then run the command again.
The site refused the new language, and the end of the line says why:
  • “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.
Nothing was added or copied. Run the command again with another code.
The agent couldn’t make a translation pass its checks in 6 steps, and the words after 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.
The run’s budget is spent. Everything before it is saved: run the command again to go on, or raise maxCostPerRunUsd in config/openrouter.ts.
Every attempt at that document failed: the words in brackets say how (a timeout, a rate limit). The other documents went on, and this one’s draft is the untouched copy. Run the command again to translate just that one. If it keeps timing out on a long page, raise timeoutSeconds in config/openrouter.ts.
OpenRouter refused OPENROUTER_API_KEY. Copy the key again from openrouter.ai/settings/keys; it starts with sk-or-v1-.
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.
Your site refused the key in .npmrc: it’s mistyped or cut short, switched off, or past its Expires date. Paste the whole key again (step 2), and run the install from site-translate, where .npmrc is.
To see each call the app made to the site, run it with 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 of fakeAttract(), 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

tests/helpers/fake_model.ts
Why: 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

tests/helpers/fake_site.ts

The wording's tests

tests/unit/wording.spec.ts

The translator's tests

tests/unit/site_translator.spec.ts

The command's tests

tests/unit/site_translate.spec.ts
Why: 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

Terminal
You should see:
Terminal

Turn it off

Revoke the site key

In the CMS, ⌘K → API Keys, open Translate the site, and switch Enabled off (or delete it). The app’s next call is refused.

Revoke the OpenRouter key

Delete it at openrouter.ai/settings/keys, then remove the lines you added to .env.

Take the language down

⌘K → 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

The command works only when you run it. Drafts it already made stay in the CMS until you publish or dismiss them.

Your site in more than one language

Languages, translating by hand, and what changes for visitors

Update the site's languages

The call that adds a language and makes it public

Testing in AdonisJS

fakeAttract() and the assertions

Turn a voice memo into a blog post draft

The same kind of agent, writing a new post