Skip to main content

The situation

Your CMS has a site agent built in, and it can check and change colors, fonts and CSS. But it works on one page, or one section, at a time, and it never compares pages. Build ten pages with it and every page can end up with a design of its own: a gray heading here, a pill-shaped button there, a band in a color that isn’t in your palette. Each one looked fine on the day. Together they make the site feel patched, and some of that text is hard to read in the light theme nobody checked. This agent checks the whole site, against itself and against WCAG contrast, and it remembers what it saw, so you can run it every week and it reads only the pages someone changed.

What you’ll build

A small TypeScript program built on LangChain Deep Agents. The SDK’s calls are the agent’s tools. It reads Site design (your palette in both themes, your fonts) and lists every page. A style-collector subagent reads the pages in batches and writes one memory file per page: each section’s background, every color in both themes, its button and font settings, its custom CSS. A contrast-checker subagent checks every text color against its background with a tool that does the WCAG math in code, never in the model. The agent then compares every page with site.md, the site’s own design as most pages use it, and finds the outliers. Where your site has two patterns, it asks you which one you meant. It proposes the fixes a section’s own settings can make, one page at a time, each change’s value before and after: 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. The agent’s long-term memory is Deep Agents memory: files under /memories/, kept in a memories folder in your project. site.md is loaded into the agent’s instructions at the start of every run. Each page’s memory file records when the page last changed, so the next run collects only the pages that are new or changed, and forgets pages you deleted. A first run reads everything.
What it can and can’t measure. It checks the colors your site is set to, in both themes, with your palette filling in every color a section leaves to it. It can’t see a page as a visitor does: text over a background image or video, and colors that are partly see-through, can’t be measured, so the report lists them under Check by eye. A color a section leaves empty follows the palette, and the palette’s own pairs (text on the page, text on a card, text on your accent color) are checked once for the whole site. Custom CSS is read and compared, not measured.
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 first run over a 50-page site costs about $0.50 to $2. Later runs read only the pages that changed, so they cost less; a weekly check that only reports costs less again (Going further).
  • Time: about 20 minutes to set up; a first run takes 10 to 20 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: One design.

Pick the Design fixes pack

Under Scopes, keep Start from a pack and pick the Design fixes pack. It ticks these two scopes, and only these:Don’t add a publish or delete scope: without one the key can’t put anything live, so even a confused agent can’t change what visitors see. You publish, in the CMS.Don’t add Write custom CSS (global:css) or Change site settings (global:write) either. A section’s custom CSS can only be written with both, and Change site settings would let the key change your whole palette and every site setting. So the agent never writes CSS, your palette or your fonts: it suggests those changes in its report, for you to make in Site design, or with the CMS’s built-in site agent, which refuses a palette change that fails contrast.Check Granted before you create the key: it should list exactly the 2 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 Design fixes 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 two 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-design.

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

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/design.ts: the contrast math

src/design.ts
Why: the part a model must never guess. contrastRatio() is the WCAG 2.2 formula (relative luminance, then (lighter + 0.05) / (darker + 0.05)), and checkContrast() holds it to AA: 4.5:1 for normal text, 3:1 for large text and for icons and borders. resolvePalette() turns Site design’s palette into a hex color per token and theme, the way the site paints it: a field you left empty keeps the color every site starts with, a light-theme color left empty borrows the dark one (except the page, card and text colors, which have their own light defaults), and an empty link color follows the accent. colorOf() reads a color the way a section stores it: a hex color, a palette token (--fetchski-accent), a background preset (raised), or page when the section has no band of its own. Anything it can’t measure, it says why.

Add src/content.ts: the SDK calls

src/content.ts
Why: everything the agent does to your site, in plain functions over the SDK. listPages() lists every page’s draft with the cheap listPagesPick() (only ids, titles and updatedAt, when the page last changed; see Find an id). readSiteDesign() reads the palette, fonts and site-wide CSS from Site design. readPageStyle() reads a page’s draft and keeps only its look: each section’s background in both themes (a …Light field left empty uses the dark one), every color field with its …Light twin, its button, font and size settings, and its custom CSS. saveStyleChanges() writes the draft, and refuses if a value changed since the agent read it, so it never overwrites an editor. It changes only a section’s colors, backgrounds and style settings: it refuses custom CSS, page settings and Site design, with the reason, so the agent puts those in its report.

Add src/memory.ts: what changed since the last run

src/memory.ts
Why: the change detection, in code, so it never depends on the model. It reads the agent’s own memory files through the same Deep Agents backend the agent writes them with. Each page’s file starts with an updated: line, the page’s updatedAt when it was collected. compareWithMemory() marks each page new (no file yet), changed (its updatedAt moved) or unchanged, deletes the old file of a page that changed so the style-collector writes it again, and deletes the files of pages that no longer exist. A run that stops halfway leaves the pages it didn’t reach without a current file, so the next run collects them. Past MAX_PAGES new or changed pages, the rest are marked next run. A draft the agent saves changes the page’s updatedAt too, so the next run reads that page again, as it now looks.

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. check_contrast resolves the colors with your site’s palette and answers the ratio, what AA needs and whether it passes. ask_owner calls LangGraph’s interrupt(): the run pauses, and your typed answer becomes the tool’s result. newRun() holds one run: the FilesystemBackend that keeps the agent’s memory in the memories folder, the pages it listed and the drafts it saved. read_page_style hands the collector the page’s updatedAt to copy into its file.

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), the task tool to start its two subagents, and write_todos from todoListMiddleware() for its plan. The style-collector only reads pages; the contrast-checker only measures. Each sees only its batch, so the main agent’s context stays small however big the site is. The CompositeBackend keeps the agent’s working notes (the /findings/ 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/site.md into the agent’s instructions at the start of each run, so it knows your design and your past answers before it reads a page. Only the main agent holds save_style_changes, and interruptOn pauses before every call to it; the MemorySaver checkpointer keeps the run’s state while it waits for you. reportOnly builds an agent with no tool to ask or to save, 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. 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. Each test keeps its memory in a fresh temporary folder. They prove that a first run remembers every page, that a second run collects only the page that changed (and forgets a deleted one), that the contrast math matches known pairs, and that a fix you reject is never written. Nothing is sent, written or spent.

Your project

When you’re done, one-design holds these files (and node_modules):
src
agent.ts
content.ts
design.ts
main.ts
memory.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/ appears after your first run: a file per page, named by its id, plus site.md (your site’s design, as the agent understands it, with your answers). Keep the folder: the next run starts from it. Open memories/site.md and correct it if the agent got your design wrong; it is in the agent’s instructions on every run. Delete the folder to start over. Set MEMORY_FOLDER in .env to keep it somewhere else. 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 values are colored (red before, green after):
Terminal
Between the progress lines the agent may think for a minute: it is reading, or comparing. Run it again and the first line changes: Found 14 pages: 0 new, 1 changed, 13 unchanged. Only the changed page is read again (the draft you approved changed it too), and the agent doesn’t ask a question it already wrote into memories/site.md. At each fix, 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 (“use --fetchski-accent for that heading”); the agent revises the fix and shows it again.
  • Skip this one writes nothing; the agent reads your reason and leaves that page 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 in both themes, and publish the drafts that look right in the CMS (open the page, Publish). Send a preview link to anyone who should see 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. Without Pages: write drafts, the message names content:pages:write and the route GET /api/v1/@apostrophecms/page.
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 before it finished, so it remembered nothing new. On a large site, lower MAX_PAGES and run it again: each run that finishes remembers its pages, and the next one reads 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 checks the whole site against its design and against WCAG contrast. It keeps its memory up to date, so each week it reads only the pages someone changed. 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 collects the new and changed pages, checks contrast and compares every page with memories/site.md, updating the memory as it goes. 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 design, untick Enabled and save. It stops working at once.

Delete the OpenRouter key

Drop a draft you won't publish

Open the page 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

Give your whole site one voice

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

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: backends

Routing /memories/ to a folder on disk, in LangChain’s docs

Deep Agents: human-in-the-loop

Approve, edit and reject, in LangChain’s docs