Skip to main content

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.
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 $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

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: Contradiction finder.

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 /site-contradictions.

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 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:
.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 site-contradictions.

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, 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

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 pauses can come at once, so each 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 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, site-contradictions 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
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 and writing its notes. 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; the agent revises the fix and shows it again.
  • Skip this one writes nothing; the agent reads your reason and doesn’t try again.
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 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

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 lists every contradiction it finds, each version quoted with the document it’s in. 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 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

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 Contradiction finder, 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.

Give your whole site one voice

The same tools, rewriting every document in the voice you approve

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: human-in-the-loop

Approve, edit and reject, in LangChain’s docs