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.
- 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
API Keys and press Enter. Click Create API Key.You should see: the Create API Key form: Label, Scopes, Expires, Request signing.Name it
One voice.Pick the Content rewrites pack
Sign its writes
Create it, and copy both secrets
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
pwd ends in /one-voice.Add package.json
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
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:@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 in .gitignore.Install
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
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
fsk_acme_… and fss_…, and your
OpenRouter key over sk-or-v1-…. Your site’s address goes in FETCHSKI_ATTRACT_URL.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:.env is text; the code turns the numbers into numbers with Number().Add src/content.ts: the SDK calls
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
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
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
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
Add src/usage.ts: tokens and cost
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
Add src/main.ts: start it
.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
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):
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 inpackage.json:
Run it
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):
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.
$ 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
FETCHSKI_ATTRACT_URL is not set
FETCHSKI_ATTRACT_URL is not set
.env. Start the agent with npm start (or pnpm start, bun run start) from
the project folder: the script loads .env.OpenRouter API key is required
OpenRouter API key is required
OPENROUTER_API_KEY to .env.User not found.
User not found.
403: the key needs a scope
403: the key needs a scope
content:pages:write and
content:blog:write.401: Invalid API key.
401: Invalid API key.
.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.401: This request must be signed
401: This request must be signed
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).Recursion limit reached
Recursion limit reached
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
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
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:Keep the reports out of git
.gitignore:Saved to reports/2026-10-28-0900.md.Email it to you
Pick one way to send it, then add it tosrc/main.ts. Both send the same email and need the
same two addresses in .env.
Nodemailer: through any mail server (SMTP)
Nodemailer: through any mail server (SMTP)
Install the mail packages
Add your mail server to .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
reports/ either way.Postmark: through your Postmark account
Postmark: through your Postmark account
Install the mail packages
Add your Postmark token to .env
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
reports/ either way.Email it at the end of src/main.ts
src/main.ts, and the last line under the one that saves the
report, still above the outro(…):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 inreports/ 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
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
"scripts" in package.json:Not emailed:, it says what to fix in .env.Schedule it
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: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: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):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
crontab -e.Revoke the site key
Delete the OpenRouter key
Drop a draft you won't publish
Related
Find where your website contradicts itself
Keep your whole site in one design
Testing
fakeSite(), factories and assertions