Skip to main content

The situation

Five people wrote your site over six years. The home page is warm, the services page reads like a contract, the FAQs sound like a call center and the blog changes voice with every author. Visitors feel it even when they can’t name it. Rewriting forty documents by hand, consistently, is a week nobody has. This agent does it in an afternoon, in the voice of your best pages, and you approve every word before it is saved.

What you’ll build

A small TypeScript program built on LangChain Deep Agents. The SDK’s calls are the agent’s tools. The agent plans the work with a to-do list, lists every page, blog post and FAQ, and asks you which two or three sound most like you at your best. It reads those, writes a voice guide, and shows it to you until you approve it. The guide is the agent’s long-term memory, a real file in your project, memories/voice-guide.md: every run starts with it in the agent’s instructions, and a weekly check can hold the whole site to it. Then it hands every other document, one at a time, to a rewriter subagent, which reads the guide and the document and proposes its rewrite. Each rewrite waits for you, with every change’s text before and after and the changed words marked: save it, keep only some of its changes, tell the agent what to change (it tries again), or skip it. A saved rewrite is a draft; facts, prices, names and links stay exactly as they were. Nothing goes live until you publish it in the CMS. As it works, the terminal shows what the run has used, in tokens and dollars, and the report ends with the total.
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 $5): it is the hard ceiling on what any run can spend.
  • Money: with the model set below (~google/gemini-flash-latest), a run over a 50-document site costs about $1 to $4: rewriting writes far more than checking. You run it once, then again only when the site has drifted; a weekly check that only reports costs far less (Going further).
  • Time: about 20 minutes to set up; a run takes 20 to 40 minutes, most of it your reading.

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: One voice.

Pick the Content rewrites pack

Under Scopes, keep Start from a pack and pick the Content rewrites pack. It ticks these three scopes, and only these:Don’t add a publish or delete scope. A write scope writes drafts and reads them; 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.Check Granted before you create the key: it should list exactly the 3 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 Content rewrites 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 three 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 /one-voice.

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, diff, 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 one-voice, 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 one-voice.

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 SDK calls

src/content.ts
Why: everything the agent does to your site, in plain functions over the SDK. listDocuments() finds every page, post and FAQ with the cheap …Pick() lists (only ids and titles, see Find an id); readWording() reads a document’s draft and keeps only its wording, each piece with a path such as sections.items.2.content; saveDraftChanges() writes the draft, and refuses if the text changed since the agent read it, so it never overwrites an editor. Only the fields in EDITABLE are ever written.

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

src/tools.ts
Why: each function becomes a LangChain tool, with a zod schema the model fills in. ask_owner calls LangGraph’s interrupt(): the run pauses, and your typed answer becomes the tool’s result.

Add src/agent.ts: the deep agent

src/agent.ts
Why: createDeepAgent() gives the agent the filesystem tools (write_file, read_file, edit_file and more) for the voice guide, the task tool to start a rewriter subagent per document (each sees only the guide and its one document, so the main agent’s context stays small however big the site is), and write_todos from todoListMiddleware() for its plan. Only the rewriter holds save_draft_changes. interruptOn pauses before every call to it, even from inside the subagent, and the MemorySaver checkpointer keeps the run’s state while it waits for you. The CompositeBackend keeps the agent’s working files for one run only, and routes /memories/ to the memories folder on disk: that is the long-term memory, as LangChain’s memory guide sets it up. The memory option loads /memories/voice-guide.md into the agent’s instructions at the start of each run, so the guide you approved is there before it reads a document, and the next run shows it to you instead of asking which pages to learn from. reportOnly builds an agent that only checks documents against that guide, for a run with nobody at the keyboard (Going further). modelFromEnv() builds the OpenRouter model from .env.

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

src/review.ts
Why: the run goes until the agent pauses. A question gets your answer; a draft gets your call, turned into the form the agent’s approval step takes (save all = approve, keep some = edit, change this or skip = reject, with a message the agent reads). Several rewriters can pause at once, so each pause gets its own answer by its id, in one new Command({ resume }). recursionLimit stops a run that goes on too long, and callbacks hands every model call to usage.

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

src/terminal.ts
Why: everything you see while the agent runs. A spinner says what the agent is doing and what the run has used so far; its questions are Markdown, set out with a blank line between paragraphs and list items; each draft shows every change’s text before and after, with the changed words marked. You save it all, pick the changes to keep, tell the agent what to change, or skip it. Ctrl+C at any prompt stops the run; the drafts saved so far stay drafts.

Add src/usage.ts: tokens and cost

src/usage.ts
Why: LangChain calls handleLLMEnd after every model call, the subagents’ too, with the answer OpenRouter sent: its token counts and cost, in US dollars. The run adds them up for the terminal and the report, so you see what each run really cost.

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

src/report.ts
Why: the end of a run, as one Markdown report: the agent’s summary, then each draft it saved with a private preview link (a page or post; FAQs have no page of their own). The link works for anyone you send it to, without signing in. Markdown reads well in a terminal, and the same report can go to a file or an email (Going further).

Add src/main.ts: start it

src/main.ts
Why: builds the site client and the model from .env, runs the agent with you at the keyboard, and shows the report, its usage and cost included.

Add test/agent.test.ts: the tests

test/agent.test.ts
Why: the tests run the whole agent, its subagents included, with no site and no model. fakeSite() stands in for your site and answers every SDK call, and LangChain’s fakeModel() plays the model’s moves in order. Nothing is sent, written or spent.

Your project

When you’re done, one-voice holds these files (and node_modules):
src
agent.ts
content.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/voice-guide.md appears after your first run, once you approve the guide. Keep it: the next run 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

The start script runs node --env-file=.env src/main.ts: Node loads .env, then runs the program. You should see the agent’s progress beside a spinner, then its questions and drafts. The lines below are the program’s own; the agent’s questions and summary are in its words, so yours will differ. In your terminal the changed words are colored (red before, green after):
Terminal
Between the progress lines the agent may think for a minute: it is reading, or writing the guide. On later runs it shows you the guide in memories/voice-guide.md instead of asking which pages to learn from. Answer the guide’s question with what to change (“less formal, and never use exclamation marks”), and it rewrites the guide and asks again. At each draft, pick with the arrow keys and Enter:
  • Save it (or Save all N changes) saves the draft.
  • Pick which changes to keep shows a checklist: Space unticks a change, Enter saves the rest.
  • Tell the agent what to change asks for your words (“buttons stay one to three words”). The agent revises the draft and shows it again, and a rule you give goes into the voice guide.
  • 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 $ 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 (open the document, Publish). Send a preview link to anyone who should read it first: it works without signing in.

If something goes wrong

Terminal
Node didn’t load .env. Start the agent 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.
Terminal
The key is missing the scope the message names. In the CMS, press ⌘K, open API Keys, open the key, tick the scope under Scopes and save. The same goes for content:pages:write and content:blog:write.
Terminal
The token in .env isn’t the one the site issued, or the key was turned off or has expired. Make a new key, and paste both of its secrets again.
The agent reads fine, but the first draft you approve fails:
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. On a large site, lower MAX_DOCUMENTS and run it again: the documents already in your voice come back unchanged. Or raise AGENT_MAX_STEPS (more steps, more cost).

Going further

Three additions, each optional, each building on the one before: keep every report as a file, have it emailed to you, and run a check every week with nobody at the keyboard.

Save each report to a file

Add src/save-report.ts

src/save-report.ts
Why: writes the report to reports/ as a Markdown file named by the date and time, such as reports/2026-10-28-0900.md. Set REPORTS_FOLDER in .env to keep them somewhere else.

Save it at the end of src/main.ts

In src/main.ts, add the save-report.ts import, add note to the import from ./terminal.ts, and put the last line just above the outro(…) at the end:
src/main.ts

Keep the reports out of git

The reports quote your site and hold private preview links. Add the folder to .gitignore:
.gitignore
You should see, at the end of your next run: Saved to reports/2026-10-28-0900.md.

Email it to you

Pick one way to send it, then add it to src/main.ts. Both send the same email and need the same two addresses in .env.

Install the mail packages

Why: Nodemailer sends email through any mail server, and marked, already in your project for the terminal, turns the Markdown report into the email’s formatted version.

Add your mail server to .env

Your email provider shows its SMTP settings: a host, a port, a user name and a password (often one made for apps). Put them together as a URL:
.env
smtps:// with port 465 is a secure connection from the start; for port 587, write smtp:// (the connection is secured once it opens). In the user name and the password, write @ as %40, : as %3A and / as %2F. Send to several people with commas: REPORT_EMAIL_TO="you@acme.example,partner@acme.example".Keep the double quotes around each value: the sender’s name has spaces and < > in it, and quotes keep a value whole.

Add src/email-report.ts

src/email-report.ts
Why: sends the report with its heading as the subject, as plain text and as formatted HTML. If the mail server refuses, the run still ends normally and says why: the report is in reports/ either way.

Install the mail packages

Why: Postmark’s Node.js library sends email through your Postmark account’s API (it brings its own types), and marked, already in your project for the terminal, turns the Markdown report into the email’s formatted version.

Add your Postmark token to .env

In Postmark, open your server, then API Tokens, and copy its Server API token. The sending address must be a confirmed sender signature or on a verified domain in that account:
.env
Send to several people with commas: REPORT_EMAIL_TO="you@acme.example,partner@acme.example".Keep the double quotes around each value: the sender’s name has spaces and < > in it, and quotes keep a value whole.

Add src/email-report.ts

src/email-report.ts
Why: sends the report with its heading as the subject, as plain text and as formatted HTML. If Postmark refuses (a wrong token, an unconfirmed sender), the run still ends normally and says why: the report is in reports/ either way.

Email it at the end of src/main.ts

Add the import at the top of src/main.ts, and the last line under the one that saves the report, still above the outro(…):
src/main.ts
You should see, at the end of your next run: Emailed to you@acme.example., and the report in your inbox a moment later.

Run it every week

A scheduled run has nobody at the keyboard, so it can’t ask you anything or wait for your yes. It runs in report-only mode instead: it checks every document against the voice guide you approved, and lists the ones that drift. Its report is saved in reports/ and emailed to you, so the two parts above come first. The agent gets no tool to ask or to save, so a report-only run never changes your site. When the report shows something to fix, run npm start at your keyboard.

Add src/report-only.ts

src/report-only.ts
Why: the same agent, built with reportOnly: it starts with memories/voice-guide.md in its instructions and checks every document against it, never changing the guide. With no guide yet (you haven’t approved one at your keyboard), it says so and stops. The report is printed, saved and emailed. If the run stops (a wrong key, a missing .env value, no OpenRouter credit left), you still get an email, with “the check stopped” in its subject and the reason in it.

Add the report script to package.json

Add this line to "scripts" in package.json:
package.json
Try it once by hand, so you know the email arrives before you schedule it:
You should see the progress lines, then the report, then:
Terminal
If the last line starts Not emailed:, it says what to fix in .env.

Schedule it

These steps are for macOS and Linux, with cron. The computer has to be on and awake at the time, so an always-on machine or a small server is best.Find the folder Node is in:
Terminal
Open your schedule with crontab -e, and add these two lines. Put the folder you just found first on the PATH line, and your project’s full path after cd:
crontab
0 8 * * 1 runs it every Monday at 8:00: minute 0, hour 8, any day of the month, any month, Monday (1). cron doesn’t load your shell’s settings, which is why the PATH line names the folder Node is in.On macOS, keep the project out of Desktop, Documents and Downloads: a scheduled job can’t open those folders unless you give cron Full Disk Access in System Settings → Privacy & Security.You should see, every Monday morning, an email like this (the report is the agent’s own words, so yours will differ):
Email
The same report is in reports/, named by its date and time.No email on Monday? Open reports/run.log in the project: every run adds its output there, starting with Report-only run, and its date and time. An entry that ends Not emailed: says what the mail server or Postmark answered. No entry for that Monday means the run never started: the computer was asleep or off at 8:00, or the cd path or the PATH line in crontab -e is wrong.

Turn it off

The agent runs only when you start it, or when your schedule does. To retire it:

Remove the schedule, if you made one

Delete its line in crontab -e.

Revoke the site key

In the CMS, ⌘K → API Keys, open One voice, untick Enabled and save. It stops working at once.

Delete the OpenRouter key

Drop a draft you won't publish

Open the document in the CMS, and choose Discard Draft from its menu. The live version is untouched.

Find where your website contradicts itself

The same tools, checking every fact your site states twice

Keep your whole site in one design

The same tools, checking every page’s colors, fonts and contrast against the rest

Testing

fakeSite(), factories and assertions

Required scopes

What each scope opens, per content type

Deep Agents: memory

Long-term memory as files, in LangChain’s docs

Deep Agents: human-in-the-loop

Approve, edit and reject, in LangChain’s docs