Skip to main content

The situation

More and more of your customers would rather read about you in their own language, and you want to get it right: your business’s name and your services’ names kept as they are, the tone still yours, every page read before it goes live. Doing it by hand means copying forty documents into a new language, then the header, footer and cookie banner, and translating them one by one. This agent does the copying and the translating, and you approve every page before it is saved.

What you’ll build

A small TypeScript program built on LangChain Deep Agents. You run it with a language: npm start -- fr "Français". Your code adds the language to your site (private, so only people signed in to the CMS see it), works out which pages, blog posts and FAQs need translating, and copies each into the language, after the forms, authors and categories they show. Your header, footer and cookie banner are one more document: the translator gets only the fields the site names as each language’s own, and your code points their links at the pages in the new language. Then the agent takes over. It plans the work with a to-do list and keeps a glossary: the names it never translates, and the rules you give it. The glossary is the agent’s long-term memory, a real file in your project, memories/glossary.md: on the first run the agent writes it and asks you to approve it, and every run after starts with it in the agent’s instructions. Then it hands every document, one at a time, to a translator subagent, which reads the glossary and the document, translates it, and checks the translation with a tool your code runs: every text there and translated, every HTML tag, link, glossary name and {year} the site fills in kept. Each translation waits for you, with every text before and after: save it, tell the agent what to change (it tries again), or skip it. A saved translation is a draft, sent for review with a preview link. Nothing goes live until you publish it in the CMS, and --publish makes the language public.
Before you start, you need:
  • Node.js 26.12.1 or later and one of npm, pnpm or Bun.
  • A person who may manage API keys on the site, to make the key below.
  • An OpenRouter account with credit, and a key made at openrouter.ai/settings/keys. Give that key a credit limit (say $20): it is the hard ceiling on what any run can spend. The terminal shows what the run has spent as it works, and the report ends with the total.
  • Time: about 20 minutes to set up; a run takes about as long as you spend reading the translations.

Make the API key

Open API Keys

Sign in to your site’s CMS, press ⌘K (Ctrl+K on Windows), type API Keys and press Enter. Click Create API Key.You should see: the Create API Key form: Label, Scopes, Expires, Request signing.

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:Don’t add a publish or delete scope. Without a publish scope the key can’t put anything live, so even a confused agent can’t change what visitors see. You publish, in the CMS. global:write could change any of your site’s settings: the program 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.

Sign its writes

Request signing: Token + signature on writes. The SDK signs every write for you with the key’s signing secret, so a stolen token alone can’t change your drafts.

Create it, and copy both secrets

Click Create key.You should see: the Secret token (fsk_acme_…) and the Signing secret (fss_…), each with a Copy button. They are shown once: copy both now. The token goes into .npmrc and both into .env, below.

Step by step

Make the project folder

Terminal
Why: every command and file below lives in this folder.You should see: nothing printed; pwd ends in /translate-site.

Add package.json

package.json
Why: the packages and their versions. deepagents is LangChain’s Deep Agents, @langchain/openrouter its OpenRouter chat model, @fetchski/attract-cms-sdk the SDK. @clack/prompts, marked, string-width and yocto-spinner draw the conversation in your terminal. Node runs the TypeScript files directly (node src/main.ts), so there is no build step; TypeScript is only for checking.

Point the @fetchski scope at your site

Do this in translate-site, 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 the install asks it for every @fetchski/… package, with your key.You should see: .npmrc beside package.json, and .npmrc in .gitignore.

Install

Then check that Node finds the two packages that matter most:
Terminal
You should see: installed. A 401 Unauthorized during the install means your site refused the key in .npmrc (mistyped, switched off or past its Expires date), or .npmrc isn’t in translate-site.

Add tsconfig.json

tsconfig.json
Why: strict type-checking. noEmit makes tsc only check, never write JavaScript, so the typecheck script is plain tsc. allowImportingTsExtensions and erasableSyntaxOnly match how Node runs .ts files itself: imports name the .ts file, and the code uses only types Node can strip.

Add your secrets

Paste the key’s Secret token and Signing secret over fsk_acme_… and fss_…, and your OpenRouter key over sk-or-v1-…. Your site’s address goes in FETCHSKI_ATTRACT_URL.
.env
.gitignore
Why: the SDK reads the three FETCHSKI_ATTRACT_* values on its own, and the OpenRouter model reads OPENROUTER_API_KEY. .gitignore keeps .env and .npmrc (your keys) out of git.The other values are yours to change, and every one has a default in the code:Every value in .env is text; the code turns the numbers into numbers with Number().

Add src/content.ts: the site's documents

src/content.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, so site.request() reaches them by route, with the key, its signature, retries and the SDK’s errors, as every SDK method does. wordingOf() keeps only the words a visitor reads, keyed as the site’s PATCH writes them back, so a save changes the words and nothing else; for the site settings it takes only the fields the site names with getGlobalTranslatedFields(), since every other setting is shared by all your languages. linksTo() points each header and footer link to one of your pages at that page in the new language; a link to another site stays as it is. problemsWith() is the check every translation must pass.

Add src/language.ts: the language and the copies

src/language.ts
Why: what needs translating is a rule, so your code decides it, not the agent: a document is up to date once it was copied from the current original and its translation written since. A second run on an unchanged site finds nothing to do and never calls the model. What a page shows is copied first, because a page keeps a related document only once it exists in the language.

Add src/tools.ts: the SDK calls as the agent's tools

src/tools.ts
Why: five tools, each a small piece of code with a plain description. check_translation and save_translation run the same check, so a translation that loses a name is never written, whatever the model says. keptNames() reads the glossary’s names from the memory file, so the names you approve are the ones checked.

Add src/agent.ts: the deep agent

src/agent.ts
Why: createDeepAgent() gives the agent a to-do list, files and subagents. The translator gets only the tools a translation needs, so the main agent’s context stays small however many documents there are. /memories/ is kept on disk between runs; memory loads the glossary into the instructions at the start of each one. interruptOn pauses every save_translation until you decide. Each model call may take ten minutes, room for a long page on a slow model, so the limit only ends a call that hangs; modelRetryMiddleware tries a failed call twice more, then tells the agent why and lets it go on.

Add src/review.ts: you, in the loop

src/review.ts
Why: each pause comes back from invoke() as an interrupt. runWithOwner() asks you, then resumes the run with your answer: approve, or reject with a message the translator reads (“the owner wants changes first”, or “skipped”). When the agent stops, your code counts what is saved, never the agent’s word for it: a document neither saved nor skipped sends the agent back for it, up to twice, and the report lists anything still not saved.

Add src/terminal.ts: the conversation, in your terminal

src/terminal.ts
Why: the terminal side of the conversation: a spinner while the agent works, with the tokens and cost so far; its questions as formatted text; each translation with every text in both languages.

Add src/usage.ts: tokens and cost

src/usage.ts
Why: LangChain calls handleLLMEnd after every model call, the subagents’ too, so one Usage counts the whole run. The cost is OpenRouter’s own figure, from its answer.

Add src/report.ts: the run's report

src/report.ts
Why: the drafts waiting for you, each page and post with a preview link that works without signing in, and what the run spent.

Add src/main.ts: start it

src/main.ts
Why: the order of a run: the language, the copies, the agent with you, the report, and the language made public only when you asked for it with --publish.

Add test/agent.test.ts: the tests

test/agent.test.ts
Why: the real agent and the real tools run, but fakeSite() stands in for your site (its request answer keeps the documents in memory) and LangChain’s fakeModel() plays the model’s moves in order. Nothing is sent, written or spent.

Your project

When you’re done, translate-site holds these files (and node_modules):
src
agent.ts
content.ts
language.ts
main.ts
report.ts
review.ts
terminal.ts
tools.ts
usage.ts
test
agent.test.ts
.env
.gitignore
.npmrc
package.json
package-lock.json
tsconfig.json
memories/glossary.md appears after your first run, once you approve it. Keep it: the next run, in this language or another, starts from it. You can edit it by hand too; delete it to start over. The lockfile is pnpm-lock.yaml with pnpm, and bun.lock with Bun.

Test it

Check the types, then run the tests. Both are scripts in package.json:
You should see: nothing from the type check (any output is a type error, with its file and line), then these lines from the tests, plus a few more counts and the run’s time:
Terminal

Run it

Give the language’s code and its name as its speakers write it:
The start script runs node --env-file=.env src/main.ts: Node loads .env, then runs the program with your language. You should see the run’s progress beside a spinner, then the agent’s questions and each translation. The lines below are the program’s own; the agent’s questions, translations and summary are in its words, and the counts are your site’s, so yours will differ:
Terminal
Answer the glossary’s question with what to change (“keep Main Street, and add our Sunday Social”), and the agent updates the glossary and asks again. At each translation, pick with the arrow keys and Enter:
  • Save it saves the translation as the document’s draft in the language, and sends it for review.
  • Tell the agent what to change asks for your words (“use vous, never tu”). The translator revises it and shows it again, and a rule you give goes into the glossary.
  • Skip this one writes nothing; the agent reads your reason and leaves that document alone.
Ctrl+C at any prompt stops the run. The drafts saved so far stay drafts, and the report lists them; the next run translates what is left. The $ figures are what OpenRouter charged, read from its own answers, never estimated. Then open each preview link, and publish the drafts that read right in the CMS (⌘K, Submitted Drafts, open each one, Publish). Your header, footer and cookie banner are the draft Header, footer and cookie banner; their links to your pages already point at the pages in the new language (/about-us becomes /fr/about-us). When the pages that matter are published, make the language public:
Terminal
It translates anything new or changed first, as always, then says Français is public: visitors see each page you have published in it.

If something goes wrong

Terminal
Give the language’s code in lowercase, such as fr, or a language and a country, such as pt-br, then its name, in quotes when it has a space. With npm, the -- before them is needed.
Terminal
The end of the line says why: that code is your main language’s, or (“/api” is already one of the site’s own addresses) it is taken by one of your site’s own addresses. Nothing was added or copied. Run it again with another code.
Terminal
Node didn’t load .env. Start the program with npm start (or pnpm start, bun run start) from the project folder: the script loads .env.
Terminal
Add OPENROUTER_API_KEY to .env.
Terminal
OpenRouter doesn’t know the key: it was mistyped, or deleted. Copy it again from openrouter.ai/settings/keys. The documents already copied are translated on the next run.
Terminal
The key is missing the scope the message names: 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. In the CMS, press ⌘K, open API Keys, open the key, tick the scope under Scopes and save. The run stopped before it overwrote anything.
Terminal
FETCHSKI_ATTRACT_SIGNING_SECRET is missing from .env. If the message ends X-Fetchski-Signature does not match this request. instead, it is the wrong secret: copy the one shown beside this key’s token (or make a new key).
Terminal
The run hit its step ceiling. Run it again: the documents already translated are up to date, so it goes on with the rest. Or raise AGENT_MAX_STEPS (more steps, more cost).
The model didn’t answer one call three times running (a timeout, an outage, a rate limit). The agent is told so and goes on; a document it couldn’t save stays a copy in the main language, and the next run translates it. If it keeps timing out on a long page, raise MODEL_CALL_SECONDS in src/agent.ts.

Going further

Another language

npm start -- es "Español" adds Spanish and translates into it, starting from the same glossary: the names you keep are the same in every language.

Keep it current

Run it again after you change your site. Only the documents that changed since their translation are copied and translated again, and each waits for you as before.

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 program’s next call is refused.

Delete the OpenRouter key

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.

Your site in more than one language

Languages, translating by hand, and what changes for visitors

Give your whole website one voice

The same kind of agent, rewriting in your voice

Testing in TypeScript

fakeSite() and the assertions

The same Recipe in AdonisJS

One command, no approvals: an OpenRouter agent with one checked save tool