The situation
Your site was written over years, by different people. The About page says you open on Saturdays; an FAQ says you don’t. One post quotes last year’s price, another the new one. Visitors notice, and nobody has the afternoon it takes to reread the whole site side by side. This agent does that reading, asks you the one question only you can answer (which version is true), then drafts the fixes.What you’ll build
A small TypeScript program built on LangChain Deep Agents. The SDK’s calls are the agent’s tools: it lists every page, blog post and FAQ, and hands them in batches to a fact-collector subagent, which reads each one and writes its facts to a notes file in the agent’s own virtual filesystem. The agent plans the work with a to-do list, compares the notes, writes down every contradiction, and asks you which version is true. Then it proposes a fix per document. Each one 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 fix is a draft. 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 $0.50 to $2. You run it when you choose, so a month costs what your runs cost. - Time: about 20 minutes to set up; a run takes 5 to 15 minutes plus your answers.
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
Contradiction finder.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 /site-contradictions.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
site-contradictions, 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
site-contradictions.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,
ls …) for its notes, the task tool to start a fact-collector subagent per batch (each sees
only its own batch, so the main agent’s context stays small), and write_todos from
todoListMiddleware() for its plan. interruptOn pauses before every save_draft_changes, and
the MemorySaver checkpointer keeps the run’s state while it waits for you. reportOnly builds
the same agent without ask_owner and save_draft_changes, 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,site-contradictions holds these files (and node_modules):
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):
- 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; the agent revises the fix and shows it again.
- Skip this one writes nothing; the agent reads your reason and doesn’t try again.
$ 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 for the
rest, 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 lists every contradiction it finds, each version quoted with the document it’s in. 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 reads every document and finds the
contradictions as usual, and reports them instead of asking you. 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
Give your whole site one voice
Keep your whole site in one design
Testing
fakeSite(), factories and assertions