> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fetchski-attract.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Keep your whole site in one design, and readable

> A LangChain deep agent remembers how every page looks, checks each text color against WCAG contrast in code, finds the sections that drift from your site's design, and drafts the fixes for you to approve.

```ts src/agent.ts wrap theme={"dark"}
import type { SiteClient } from '@fetchski/attract-cms-sdk';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { MemorySaver } from '@langchain/langgraph';
import { ChatOpenRouter } from '@langchain/openrouter';
import { CompositeBackend, createDeepAgent, StateBackend } from 'deepagents';
import { todoListMiddleware } from 'langchain';
import { type Run, siteTools } from './tools.ts';

const CHECK = `You keep a small business website in one design: the same palette, fonts, section backgrounds
and button styles on every page, with text people can read. Different pages were built at different
times, so some drift: a section with its own one-off color, a stray font, a button shaped unlike the rest.

Your long-term memory is /memories/, kept between runs:
- /memories/site.md: the site's design: its palette and fonts, the patterns most pages share, and the
  owner's choices. It is loaded into your instructions at the start of every run.
- /memories/pages/<page>.md: one file per page, written by the style-collector. list_pages names each
  one, and knows from it whether the page changed since.

Work in this order:
1. Plan your work with write_todos.
2. Call read_site_design once, and list_pages once.
3. Hand every new or changed page to the style-collector subagent with the task tool, about eight at a
   time: give the id, title and memory file of each. Unchanged pages already have a current memory file;
   pages marked "next run" wait for a later run, so leave them out of this one.
4. Write /memories/site.md, or update it with edit_file if it already exists: the palette from
   read_site_design, which colors the site changed, the fonts, and the patterns most page files share:
   section backgrounds, heading and eyebrow colors, button variants and corners, font overrides.
5. Hand the page files to the contrast-checker subagent with the task tool, about ten at a time, each
   batch with its own findings file: /findings/contrast-1.md, /findings/contrast-2.md and so on.
6. Read the findings. Compare every page file with /memories/site.md, and write /findings/design.md: each
   outlier with its page, section path and field: a hex color off the palette where the site uses a
   palette token, a font or font override no other page uses, a button variant or corner unlike the
   rest, custom CSS that sets colors or fonts the palette already has.`;

const FIX = `
7. Where the site has two patterns and you can't tell which one is intended (two button corners, two
   eyebrow colors), call ask_owner once per choice: name each one and the pages that use it. Write the
   answer into /memories/site.md, so no later run asks again.
8. For each page with fixes a section setting can make (a one-off color back to its palette token, a
   button to the site's pattern, a text color that fails contrast), call save_style_changes once with
   every change on that page. The owner approves each page. If the owner sends one back with notes,
   change it to match and call save_style_changes again; if they skip one, don't try again.
   Site design (the palette and fonts) and custom CSS are never yours to change: suggest them instead.
9. Finish with a short summary: the contrast failures, the outliers, the drafts you saved, then
   "Suggested for Site design" (each palette or font change, with the pair it fixes) and "Check by eye"
   (each section with an image or video behind its text, and each pair the check couldn't measure).

Each ask_owner call asks ONE question. Write it in Markdown, with a blank line between paragraphs and
between list items.`;

const REPORT_ONLY = `
7. Nobody is at the keyboard for this run, and you can't change the site. Finish with a report for the
   owner: "Contrast" (each failing pair: page, section, field, theme, ratio and what it needs, the
   palette's own pairs first), "Outliers" (each with what the rest of the site does), "Suggested for
   Site design" (palette and font changes, with the pair each fixes) and "Check by eye" (each section
   with an image or video behind its text, and each pair the check couldn't measure).`;

const STYLE_COLLECTOR = `You record how website pages look. For each page you are given, call read_page_style,
then write its memory file (the path you are given) with write_file, in this shape:

# <title>
id: <id>
updated: <updated, copied exactly>
Page: <its own colors and settings, or none>

## <section path> (<section type>)
- background: dark <value>, light <value>
- <field>: dark <value>, light <value>   (one line per color; write "default" for '')
- settings: <field> <value>, <field> <value> (every setting)
- custom CSS: <the CSS, or none>

Copy every value exactly as read_page_style gives it; don't judge it. Answer with only the files you wrote.`;

const CONTRAST_CHECKER = `You check that the text on a website can be read. Read each page file you are
given with read_file. In each section, call check_contrast for every color that colors text or an icon
(fields named for a heading, title, eyebrow, intro, text, label, link, meta, quote, icon) against the
section's background: the dark value in the dark theme, the light value in the light theme. A solid
button's TextColor goes against its Color (its fill). Size: large for headings and big titles, ui for
icons and borders, normal for the rest. Skip colors that are "default": they follow the palette.

Write the findings file you are told, one line per failing pair:
- <page title> | <section path> <field> | <theme> | <ratio>, needs <needs>
Then "Check by eye:", one line per pair check_contrast couldn't measure, with its why. Answer with only
the file's path.`;

export interface AgentOptions {
  /** Nobody is at the keyboard: the agent only reports, with no tool to ask or to write. */
  reportOnly?: boolean;
}

/** The deep agent: our SDK's operations as its tools, two subagents, long-term memory, and the owner's approval before any draft is written. */
export function buildAgent(
  site: SiteClient,
  model: BaseChatModel,
  run: Run,
  log: (line: string) => void,
  { reportOnly = false }: AgentOptions = {}
) {
  const tools = siteTools(site, run, log);
  return createDeepAgent({
    model,
    systemPrompt: CHECK + (reportOnly ? REPORT_ONLY : FIX),
    tools: reportOnly
      ? [tools.readSiteDesign, tools.listPages]
      : [tools.readSiteDesign, tools.listPages, tools.askOwner, tools.saveStyleChanges],
    middleware: [todoListMiddleware()],
    subagents: [
      {
        name: 'style-collector',
        description: "Reads a batch of pages' styles and writes one memory file per page.",
        systemPrompt: STYLE_COLLECTOR,
        tools: [tools.readPageStyle]
      },
      {
        name: 'contrast-checker',
        description:
          'Checks every text color in a batch of page files against its background, and writes the failures to a findings file.',
        systemPrompt: CONTRAST_CHECKER,
        tools: [tools.checkContrast]
      }
    ],
    // Long-term memory: /memories/ is a folder on disk, kept between runs; every other file lives
    // for one run. site.md is loaded into the agent's instructions at the start of each run.
    backend: new CompositeBackend(new StateBackend(), { '/memories/': run.memories }),
    memory: ['/memories/site.md'],
    // Nothing is written without the owner: approve it, edit it, or reject it.
    interruptOn: { save_style_changes: { allowedDecisions: ['approve', 'edit', 'reject'] } },
    checkpointer: new MemorySaver()
  });
}

/** The OpenRouter model and settings named in .env. ChatOpenRouter reads OPENROUTER_API_KEY itself. */
export function modelFromEnv() {
  return new ChatOpenRouter({
    model: process.env.OPENROUTER_MODEL || '~google/gemini-flash-latest',
    temperature: Number(process.env.OPENROUTER_TEMPERATURE || 0),
    maxTokens: Number(process.env.OPENROUTER_MAX_TOKENS || 16000)
  });
}
```

```ts src/tools.ts wrap theme={"dark"}
import { resolve } from 'node:path';
import type { SiteClient } from '@fetchski/attract-cms-sdk';
import { interrupt } from '@langchain/langgraph';
import { FilesystemBackend } from 'deepagents';
import { tool } from 'langchain';
import { z } from 'zod';
import { listPages, readPageStyle, readSiteDesign, saveStyleChanges } from './content.ts';
import { checkContrast, paletteContrast } from './design.ts';
import { compareWithMemory, type PageStatus } from './memory.ts';

/** One run: the agent's long-term memory, the pages it listed, and the drafts it saved (id → title). */
export interface Run {
  memories: FilesystemBackend;
  pages: PageStatus[];
  saved: Map<string, string>;
}

/**
 * A fresh run. Its long-term memory is a folder on disk (MEMORY_FOLDER in .env, else `memories`), the
 * Deep Agents FilesystemBackend the agent sees as /memories/.
 */
export function newRun(memoryFolder = process.env.MEMORY_FOLDER || 'memories'): Run {
  const memories = new FilesystemBackend({ rootDir: resolve(memoryFolder), virtualMode: true });
  return { memories, pages: [], saved: new Map() };
}

/** Our SDK's operations, as the agent's tools. `log` shows the agent's progress. */
export function siteTools(site: SiteClient, run: Run, log: (line: string) => void) {
  // Site design is read once a run, by whichever tool needs it first.
  let design: ReturnType<typeof readSiteDesign> | undefined;
  const siteDesign = () => {
    design ??= readSiteDesign(site);
    return design;
  };

  const listPagesTool = tool(
    async () => {
      const { pages, removed } = await compareWithMemory(run.memories, await listPages(site));
      run.pages = pages;
      const count = (status: string) => pages.filter((page) => page.status === status).length;
      log(
        `Found ${pages.length} pages: ${count('new')} new, ${count('changed')} changed, ${count('unchanged')} unchanged.`
      );
      if (count('next run') > 0) {
        log(`${count('next run')} more wait for the next run (MAX_PAGES).`);
      }
      if (removed.length > 0) {
        log(`Forgot ${removed.length} page(s) that no longer exist.`);
      }
      return JSON.stringify(pages.map(({ updatedAt, ...page }) => page));
    },
    {
      name: 'list_pages',
      description:
        'Lists every page: id, title, status (new, changed or unchanged since the last run, or next run: left for a later run) and its memory file. Call it once.',
      schema: z.object({})
    }
  );

  const readSiteDesignTool = tool(
    async () => {
      const { palette, customized, fonts, customCss } = await siteDesign();
      log('Read Site design.');
      return JSON.stringify({
        palette,
        customized,
        paletteContrast: paletteContrast(palette),
        fonts,
        customCss
      });
    },
    {
      name: 'read_site_design',
      description:
        'Reads Site design: the palette in both themes (each token as a hex color), which palette colors the site changed, ' +
        'the contrast of the palette’s own text pairs, the three fonts, and the site-wide custom CSS.',
      schema: z.object({})
    }
  );

  const readPageStyleTool = tool(
    async (args) => {
      const style = await readPageStyle(site, args.id);
      log(`Read the style of "${style.title}".`);
      // The page's last-modified time, for its memory file: the next run compares it with the page's.
      const updated = run.pages.find((page) => page.id === args.id)?.updatedAt ?? '';
      return JSON.stringify({ updated, ...style });
    },
    {
      name: 'read_page_style',
      description:
        "Reads one page's look: when it last changed (updated), its own style settings, then each section's path, type, background in both themes, " +
        "colors (dark and light; '' means not set, so the palette's), other style settings and custom CSS.",
      schema: z.object({ id: z.string().describe('The id from list_pages') })
    }
  );

  const checkContrastTool = tool(
    async (args) => {
      const { palette } = await siteDesign();
      return JSON.stringify(
        checkContrast(args.text, args.background, args.theme, args.size, palette)
      );
    },
    {
      name: 'check_contrast',
      description:
        'Measures one text color against its background in one theme, by the WCAG 2.2 formula: the ratio, what AA ' +
        'needs (4.5 for normal text, 3 for large text and for icons and borders), and whether it passes. Takes colors ' +
        'as read_page_style gives them: a hex color, a palette token like --fetchski-accent, a background preset, or page.',
      schema: z.object({
        text: z.string().describe('The text (or icon) color'),
        background: z.string().describe('The background it sits on'),
        theme: z.enum(['dark', 'light']),
        size: z
          .enum(['normal', 'large', 'ui'])
          .describe('large: headings and big titles; ui: icons and borders')
      })
    }
  );

  const saveStyleChangesTool = tool(
    async (args) => {
      const answer = await saveStyleChanges(site, args.id, args.changes);
      if (answer.startsWith('Saved')) {
        run.saved.set(args.id, args.title);
      }
      return answer;
    },
    {
      name: 'save_style_changes',
      description:
        "Saves changes to ONE page's section settings as a draft, once the owner approves them: colors, backgrounds " +
        'and style settings, never custom CSS. `path` is the section path from read_page_style, a dot, and the field ' +
        '(sections.items.2.ctaRadius); `before` is the exact value read_page_style gave.',
      schema: z.object({
        id: z.string(),
        title: z.string().describe("The page's title, shown to the owner"),
        changes: z
          .array(z.object({ path: z.string(), before: z.string(), after: z.string() }))
          .min(1)
          .describe('Every change to this page, each at its path')
      })
    }
  );

  const askOwner = tool(
    // Pauses the run until the owner answers; their answer is the tool's result.
    async (args) => String(interrupt({ question: args.question })),
    {
      name: 'ask_owner',
      description: 'Asks the website owner one question, and waits for their answer.',
      schema: z.object({ question: z.string() })
    }
  );

  return {
    listPages: listPagesTool,
    readSiteDesign: readSiteDesignTool,
    readPageStyle: readPageStyleTool,
    checkContrast: checkContrastTool,
    saveStyleChanges: saveStyleChangesTool,
    askOwner
  };
}
```

```ts src/design.ts wrap theme={"dark"}
/** The site's two themes. Dark is the default; visitors can switch to light. */
export type Theme = 'dark' | 'light';

/** Each palette token's color in each theme, as `#rrggbb`. */
export type Palette = Record<Theme, Record<string, string>>;

/** How big the text is: WCAG asks 4.5:1 for normal text, 3:1 for large text and for icons and borders. */
export type Size = 'normal' | 'large' | 'ui';

// The colors every site starts with, per palette token: [dark, light]. Site design → Colors
// overrides them; a field left empty there keeps these.
const BUILT_IN: Record<string, [string, string]> = {
  accent: ['#625bff', '#544dee'],
  'on-accent': ['#ffffff', '#ffffff'],
  mint: ['#34ddb3', '#0fa380'],
  coral: ['#ff8e63', '#d8551f'],
  surface: ['#09090b', '#e4e4e7'],
  raised: ['#18181b', '#d9d9de'],
  sunken: ['#0e0e11', '#cfcfd6'],
  hill: ['#121215', '#d8d8dd'],
  ink: ['#f4f4f5', '#18181b'],
  'ink-muted': ['#a1a1aa', '#52525b'],
  success: ['#34d399', '#059669'],
  warning: ['#fbbf24', '#d97706'],
  danger: ['#f87171', '#dc2626'],
  info: ['#60a5fa', '#2563eb']
};

// Site design → Colors: each field's palette token. A neutral's light color never borrows its dark one.
const PALETTE_FIELDS = {
  paletteAccent: 'accent',
  paletteOnAccent: 'on-accent',
  paletteSecondary: 'mint',
  paletteTertiary: 'coral',
  paletteLink: 'link',
  paletteSurface: 'surface',
  paletteRaised: 'raised',
  paletteSunken: 'sunken',
  paletteInk: 'ink',
  paletteInkMuted: 'ink-muted',
  paletteSuccess: 'success',
  paletteWarning: 'warning',
  paletteDanger: 'danger',
  paletteInfo: 'info'
} as const;
const NEUTRAL = new Set(['surface', 'raised', 'sunken', 'ink', 'ink-muted']);

/** The Global fields that hold the palette, both themes: `paletteAccent`, `paletteAccentLight`, … */
export const PALETTE_FIELD_NAMES = Object.keys(PALETTE_FIELDS).flatMap((field) => [
  field,
  `${field}Light`
]) as (keyof typeof PALETTE_FIELDS | `${keyof typeof PALETTE_FIELDS}Light`)[];

// The text and background pairs every page relies on: [text token, background token].
const PALETTE_PAIRS: [string, string][] = [
  ['ink', 'surface'],
  ['ink', 'raised'],
  ['ink-muted', 'surface'],
  ['on-accent', 'accent'],
  ['link', 'surface']
];

/** A color as `#rrggbb`, or null: `#rgb`, `#rrggbb`, and `#rrggbbff` (fully opaque) are read. */
function hex(value: string): string | null {
  const match = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{6}ff)$/i.exec(value.trim());
  if (!match?.[1]) {
    return null;
  }
  const digits = match[1].slice(0, 6);
  const full = digits.length === 3 ? [...digits].map((c) => c + c).join('') : digits;
  return `#${full.toLowerCase()}`;
}

/** `share` of `top` laid over `bottom`, the way a browser paints a see-through color. */
function blend(top: string, bottom: string, share: number): string {
  const channels = (color: string) =>
    [1, 3, 5].map((at) => Number.parseInt(color.slice(at, at + 2), 16));
  const [a, b] = [channels(top), channels(bottom)];
  const mixed = a.map((value, index) => Math.round(value * share + (b[index] ?? 0) * (1 - share)));
  return `#${mixed.map((value) => value.toString(16).padStart(2, '0')).join('')}`;
}

/** The palette the site renders, from Global's palette fields over the built-in colors. */
export function resolvePalette(fields: Record<string, unknown>): Palette {
  const palette: Palette = { dark: {}, light: {} };
  const set = (name: string) => (typeof fields[name] === 'string' ? hex(fields[name]) : null);
  for (const [field, token] of Object.entries(PALETTE_FIELDS)) {
    const [darkDefault, lightDefault] = BUILT_IN[token] ?? [];
    const dark = set(field) ?? darkDefault;
    const light = set(`${field}Light`) ?? (NEUTRAL.has(token) ? null : set(field)) ?? lightDefault;
    if (dark) palette.dark[token] = dark;
    if (light) palette.light[token] = light;
  }
  for (const theme of ['dark', 'light'] as const) {
    const colors = palette[theme];
    colors.link ??= colors.accent ?? '';
    colors.hill = BUILT_IN.hill?.[theme === 'dark' ? 0 : 1] ?? '';
    // The soft accent tint is the accent, see-through, over the page.
    colors['accent-soft'] = blend(
      colors.accent ?? '',
      colors.surface ?? '',
      theme === 'dark' ? 0.16 : 0.14
    );
  }
  return palette;
}

/** The palette fields this site changed: the rest are the built-in colors. */
export function customizedFields(fields: Record<string, unknown>): string[] {
  return PALETTE_FIELD_NAMES.filter(
    (name) => typeof fields[name] === 'string' && fields[name] !== ''
  );
}

/**
 * A color as the site paints it in one theme: a hex color, a palette token (`--fetchski-accent`),
 * a background preset (`surface`, `accent-soft`) or `page` (no band: the page shows through).
 * Answers `#rrggbb`, or why it can't be measured.
 */
export function colorOf(
  value: string,
  theme: Theme,
  palette: Palette
): { color: string } | { why: string } {
  const text = value.trim();
  if (!text || text === 'default') {
    return { why: 'not set: it follows the palette' };
  }
  if (text === 'image or video') {
    return { why: 'the background is an image or video' };
  }
  const token =
    /^(?:var\()?--fetchski-([a-z-]+)\)?$/.exec(text)?.[1] ?? (text === 'page' ? 'surface' : text);
  const fromPalette = palette[theme][token];
  if (fromPalette) {
    return { color: fromPalette };
  }
  const color = hex(text);
  if (color) {
    return { color };
  }
  if (/^#[0-9a-f]{8}$/i.test(text)) {
    return { why: `${text} is partly see-through, so it depends on what is behind it` };
  }
  return { why: `"${text}" is not a plain color` };
}

function channel(value: number): number {
  const c = value / 255;
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}

/** WCAG 2.2 relative luminance of a `#rrggbb` color: 0 is black, 1 is white. */
export function luminance(color: string): number {
  const n = Number.parseInt(color.slice(1), 16);
  return (
    0.2126 * channel((n >> 16) & 255) + 0.7152 * channel((n >> 8) & 255) + 0.0722 * channel(n & 255)
  );
}

/** The WCAG contrast ratio of two `#rrggbb` colors, from 1 (none) to 21 (black on white). */
export function contrastRatio(a: string, b: string): number {
  const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
  return (light + 0.05) / (dark + 0.05);
}

/** One text and background pair, checked against WCAG AA. */
export type ContrastResult =
  | {
      theme: Theme;
      text: string;
      background: string;
      ratio: number;
      needs: number;
      passes: boolean;
    }
  | { theme: Theme; measured: false; why: string };

/** Checks a text color against its background in one theme. The math is here, never the model's. */
export function checkContrast(
  text: string,
  background: string,
  theme: Theme,
  size: Size,
  palette: Palette
): ContrastResult {
  const fore = colorOf(text, theme, palette);
  const back = colorOf(background, theme, palette);
  if ('why' in fore || 'why' in back) {
    const why =
      'why' in back ? `background: ${back.why}` : `text: ${'why' in fore ? fore.why : ''}`;
    return { theme, measured: false, why };
  }
  const ratio = contrastRatio(fore.color, back.color);
  const needs = size === 'normal' ? 4.5 : 3;
  return {
    theme,
    text: fore.color,
    background: back.color,
    ratio: Math.floor(ratio * 100) / 100,
    needs,
    passes: ratio >= needs
  };
}

/** The palette's own text pairs, checked in both themes. */
export function paletteContrast(palette: Palette): (ContrastResult & { pair: string })[] {
  return (['dark', 'light'] as const).flatMap((theme) =>
    PALETTE_PAIRS.map(([text, background]) => ({
      pair: `${text} on ${background}`,
      ...checkContrast(`--fetchski-${text}`, `--fetchski-${background}`, theme, 'normal', palette)
    }))
  );
}
```

```ts src/content.ts wrap theme={"dark"}
import type { SiteClient } from '@fetchski/attract-cms-sdk';
import { customizedFields, PALETTE_FIELD_NAMES, type Palette, resolvePalette } from './design.ts';

/** A page, and when its draft last changed. */
export interface PageRow {
  id: string; // the draft's id, `…:en:draft`
  title: string;
  updatedAt: string;
}

/** A color field in both themes, as stored: '' when it isn't set (the section's default, from the palette). */
export interface ColorField {
  field: string;
  dark: string;
  light: string;
}

/** One section's look: where it sits, its background in each theme, its colors, its other settings, its custom CSS. */
export interface SectionStyle {
  path: string;
  type: string;
  background: { dark: string; light: string } | null;
  colors: ColorField[];
  settings: Record<string, unknown>;
  customCss: string;
}

/** One change to a section's setting: the value at `path` goes from `before` to `after`. */
export interface Change {
  path: string;
  before: string;
  after: string;
}

// The page fields that hold sections. Fixes are made inside these only.
const AREAS = ['sections', 'aboveSections', 'belowSections'] as const;

// Settings that are part of a section's look (colors and backgrounds are read on their own).
const SETTING = /variant|radius|shape|style|size|weight|animation|width|font/i;
const COLOR = /Color$/;
const BACKGROUND = /Bg(Light)?(Custom)?$/;

/** Every page on the site, as drafts: the latest version, published or not. */
export async function listPages(site: SiteClient): Promise<PageRow[]> {
  const pages = await site
    .pages()
    .listPagesPick(['title', 'type', 'updatedAt'], { aposMode: 'draft' });
  return pages
    .filter((page) => page.type !== '@apostrophecms/archive-page')
    .map((page) => ({ id: page.id, title: String(page.title), updatedAt: String(page.updatedAt) }));
}

/** The site's design from Site design: the palette in both themes, the fonts and the site-wide custom CSS. */
export async function readSiteDesign(site: SiteClient) {
  const fonts = ['Display', 'Body', 'Accent'] as const;
  const fontFields = fonts.flatMap((slot) => [
    `font${slot}Source`,
    `font${slot}Curated`,
    `font${slot}CdnFamily`
  ]) as `font${(typeof fonts)[number]}${'Source' | 'Curated' | 'CdnFamily'}`[];
  const [global = {}] = await site
    .globalContent()
    .listGlobalContentPick([...PALETTE_FIELD_NAMES, ...fontFields, 'designCss']);
  const fields = global as Record<string, unknown>;
  const font = (slot: string) => {
    const source = String(fields[`font${slot}Source`] ?? 'default');
    if (source === 'curated') return String(fields[`font${slot}Curated`]);
    if (source === 'cdn') return String(fields[`font${slot}CdnFamily`]);
    return source === 'upload' ? 'uploaded font files' : 'the built-in font';
  };
  return {
    palette: resolvePalette(fields) satisfies Palette,
    customized: customizedFields(fields),
    fonts: { display: font('Display'), body: font('Body'), accent: font('Accent') },
    customCss: cssOf(fields.designCss)
  };
}

/** A custom CSS field's CSS: the site stores `{ css }`. */
function cssOf(value: unknown): string {
  if (typeof value === 'string') return value;
  return value && typeof value === 'object' ? String((value as { css?: unknown }).css ?? '') : '';
}

/** A section's background in each theme: a palette preset, a hex color, `page` (none) or `image or video`. */
function backgroundOf(widget: Record<string, unknown>): SectionStyle['background'] {
  for (const [key, value] of Object.entries(widget)) {
    if (/BackgroundType$/.test(key) && (value === 'image' || value === 'video')) {
      return { dark: 'image or video', light: 'image or video' };
    }
  }
  const select = Object.keys(widget).find((key) => key.endsWith('Bg'));
  if (!select) {
    return null;
  }
  const pick = (choice: unknown, custom: unknown) =>
    choice === 'custom'
      ? String(custom ?? '')
      : !choice || choice === 'transparent'
        ? 'page'
        : String(choice);
  const dark = pick(widget[select], widget[`${select}Custom`]);
  const lightChoice = widget[`${select}Light`];
  const light =
    !lightChoice || lightChoice === 'inherit'
      ? dark
      : pick(lightChoice, widget[`${select}LightCustom`]);
  return { dark, light };
}

/** The look of one widget's own fields: its colors (with their light twins) and its other settings. */
function lookOf(fields: Record<string, unknown>) {
  const colors: ColorField[] = [];
  const settings: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(fields)) {
    if (key.startsWith('_') || value === '' || value === null || value === undefined) continue;
    const field = key.replace(/Light$/, '');
    if (COLOR.test(field)) {
      if (!colors.some((color) => color.field === field)) {
        const dark = String(fields[field] ?? '');
        colors.push({ field, dark, light: String(fields[`${field}Light`] || dark) });
      }
    } else if (SETTING.test(key) && !BACKGROUND.test(key)) {
      if (typeof value !== 'object' || (Array.isArray(value) && value.length > 0)) {
        settings[key] = value;
      }
    }
  }
  return { colors, settings };
}

/** Every section in an area with its path, and the widgets nested in it (a layout's columns) that have a look of their own. */
function sectionsIn(area: unknown, path: string, into: SectionStyle[], nested = false) {
  const items = (area as { items?: unknown[] } | undefined)?.items ?? [];
  for (const [index, item] of items.entries()) {
    const widget = item as Record<string, unknown>;
    const at = `${path}.items.${index}`;
    const { colors, settings } = lookOf(widget);
    const section = {
      path: at,
      type: String(widget.type),
      background: backgroundOf(widget),
      colors,
      settings,
      customCss: cssOf(widget.sectionCss)
    };
    if (
      !nested ||
      section.background ||
      colors.length ||
      Object.keys(settings).length ||
      section.customCss
    ) {
      into.push(section);
    }
    for (const [key, value] of Object.entries(widget)) {
      if ((value as { metaType?: string } | null)?.metaType === 'area') {
        sectionsIn(value, `${at}.${key}`, into, true);
      }
    }
  }
}

/** A page's draft, as a plain object. */
async function readDraft(site: SiteClient, id: string): Promise<Record<string, unknown>> {
  return { ...(await site.pages().getPage({ id: id.replace(/:published$/, ':draft') })) };
}

/** A page's look: its own style settings, then every section's. */
export async function readPageStyle(site: SiteClient, id: string) {
  const page = await readDraft(site, id);
  const sections: SectionStyle[] = [];
  for (const area of AREAS) {
    sectionsIn(page[area], area, sections);
  }
  const own = Object.fromEntries(
    Object.entries(page).filter(
      ([key]) => !(AREAS as readonly string[]).includes(key) && !key.startsWith('seo')
    )
  );
  return { title: String(page.title), page: lookOf(own), sections };
}

/** Why a change can't be saved, or null when it can. */
function refusal(change: Change): string | null {
  const [area = '', ...rest] = change.path.split('.');
  const field = rest.at(-1) ?? '';
  if (!(AREAS as readonly string[]).includes(area) || rest.length < 3) {
    return `"${change.path}" is not a section's setting. Page settings, Site design and fonts are the owner's: put it in your summary.`;
  }
  if (/css/i.test(field)) {
    return `"${change.path}" is custom CSS. Writing CSS needs the global:css scope, which this key doesn't have: put the CSS change in your summary.`;
  }
  const isColor = COLOR.test(field.replace(/Light$/, ''));
  if (!isColor && !SETTING.test(field) && !BACKGROUND.test(field)) {
    return `"${field}" is not a color, background or style setting.`;
  }
  if (
    isColor &&
    change.after &&
    !/^(#[0-9a-f]{3}|#[0-9a-f]{6}|#[0-9a-f]{8}|--fetchski-[a-z-]+)$/i.test(change.after)
  ) {
    return `"${change.after}" is not a color the site takes: write a palette token such as --fetchski-accent, or a hex color such as #1f2937.`;
  }
  return null;
}

/**
 * Writes the changes to the page's DRAFT. Each change must still match what the agent read
 * (`before`), so an edit someone made meanwhile is never overwritten. Answers in words, for the agent.
 */
export async function saveStyleChanges(
  site: SiteClient,
  id: string,
  changes: readonly Change[]
): Promise<string> {
  const draftId = id.replace(/:published$/, ':draft');
  const page = await readDraft(site, draftId);
  const areas = new Map<string, Record<string, unknown>>();
  for (const change of changes) {
    const refused = refusal(change);
    if (refused) {
      return `Not saved: ${refused}`;
    }
    const [area = '', ...rest] = change.path.split('.');
    if (!areas.has(area)) {
      areas.set(area, structuredClone(page[area]) as Record<string, unknown>);
    }
    let parent = areas.get(area);
    for (const key of rest.slice(0, -1)) {
      parent = parent?.[key] as Record<string, unknown> | undefined;
    }
    const last = rest.at(-1) ?? '';
    if (!parent || String(parent[last] ?? '') !== change.before) {
      return `Not saved: "${change.path}" changed since you read it. Read the page again.`;
    }
    parent[last] = change.after;
  }
  await site.pages().updatePage({
    id: draftId,
    sections: areas.get('sections'),
    aboveSections: areas.get('aboveSections'),
    belowSections: areas.get('belowSections')
  });
  return `Saved ${changes.length} change(s) to the draft of ${draftId}. Nothing is live until a person publishes it.`;
}

/** A private link to a page's draft, for anyone you send it to: no sign-in. */
export async function previewLink(site: SiteClient, id: string): Promise<string> {
  const page = await readDraft(site, id);
  const shared = await site.pages().sharePage({ id, share: true });
  return `${String(page.url)}?${new URLSearchParams({ aposShareKey: shared.aposShareKey ?? '', aposShareId: id })}`;
}
```

```ts src/memory.ts wrap theme={"dark"}
import type { FilesystemBackend } from 'deepagents';
import type { PageRow } from './content.ts';

/** A page, and whether it changed since its memory file was written. */
export interface PageStatus extends PageRow {
  status: 'new' | 'changed' | 'unchanged' | 'next run';
  memoryFile: string; // where the agent keeps its notes on this page
}

/** The most new or changed pages one run reads: a ceiling on time and AI cost. The rest wait for the next run. */
export const MAX_PAGES = Number(process.env.MAX_PAGES || 80);

/** A page's memory file, as the agent sees it: `/memories/pages/<page>.md`. */
export function memoryFileFor(id: string): string {
  return `/memories/pages/${id.split(':')[0]}.md`;
}

/** The same file inside the memories backend, which is mounted at /memories/. */
const inMemories = (file: string) => file.replace(/^\/memories/, '');

/** The page's last-modified time, from the `updated:` line the style-collector copies into its memory file. */
async function updatedInMemory(memories: FilesystemBackend, id: string) {
  const note = await memories.read(inMemories(memoryFileFor(id)), 0, 5);
  return note.error ? null : (/^updated: (.+)$/m.exec(String(note.content))?.[1]?.trim() ?? '');
}

/**
 * Compares every page with its memory file: new (no file yet), changed (the page's last-modified time
 * isn't the one its file records) or unchanged. Past MAX_PAGES, a new or changed page waits for the
 * next run. A changed page's old file is deleted for the style-collector to write again, and the files
 * of pages that no longer exist are deleted.
 */
export async function compareWithMemory(
  memories: FilesystemBackend,
  pages: readonly PageRow[]
): Promise<{ pages: PageStatus[]; removed: string[] }> {
  const statuses: PageStatus[] = [];
  let toRead = 0;
  for (const page of pages) {
    const updated = await updatedInMemory(memories, page.id);
    let status: PageStatus['status'] =
      updated === null ? 'new' : updated === page.updatedAt ? 'unchanged' : 'changed';
    if (status !== 'unchanged' && ++toRead > MAX_PAGES) {
      status = 'next run';
    }
    if (status === 'changed') {
      await memories.delete(inMemories(memoryFileFor(page.id)));
    }
    statuses.push({ ...page, status, memoryFile: memoryFileFor(page.id) });
  }
  const live = new Set(statuses.map((page) => inMemories(page.memoryFile)));
  const removed = ((await memories.ls('/pages/')).files ?? [])
    .map((file) => file.path)
    .filter((path) => !live.has(path));
  for (const path of removed) {
    await memories.delete(path);
  }
  return { pages: statuses, removed };
}
```

```ts src/review.ts wrap theme={"dark"}
import { randomUUID } from 'node:crypto';
import { Command } from '@langchain/langgraph';
import type { buildAgent } from './agent.ts';
import type { Change } from './content.ts';
import type { Usage } from './usage.ts';

/** A draft the agent wants to save: the document, and every change to it. */
export interface Draft {
  kind: string;
  title: string;
  changes: Change[];
}

/** Your call on a draft: save it all, keep some changes, send it back with notes, or skip it. */
export type Choice =
  | { type: 'all' }
  | { type: 'some'; keep: number[] }
  | { type: 'revise'; feedback: string }
  | { type: 'skip'; reason: string };

/** The person at the keyboard. src/terminal.ts is the real one; the tests script one. */
export interface Owner {
  /** The agent is working (a spinner, in the terminal). */
  busy?(): void;
  /** Answers the agent's question. */
  answer(question: string): Promise<string>;
  /** Decides on a draft the agent wants to save. */
  decide(draft: Draft): Promise<Choice>;
}

/** Nobody at the keyboard: a report-only run, whose agent has no tool to ask or to save. */
export const nobody: Owner = {
  answer: async () => {
    throw new Error('A report-only run never asks.');
  },
  decide: async () => {
    throw new Error('A report-only run never saves.');
  }
};

interface ActionRequest {
  name: string;
  args: { kind?: string; id?: string; title?: string; changes?: Change[] };
}

type Decision =
  | { type: 'approve' }
  | { type: 'reject'; message: string }
  | { type: 'edit'; editedAction: { name: string; args: ActionRequest['args'] } };

/** The most steps (model calls and tool calls) one run may take: a ceiling on AI cost. */
const MAX_STEPS = Number(process.env.AGENT_MAX_STEPS ?? 400);

/**
 * Runs the agent to the end. Each time it pauses, the owner answers its question, or decides on the
 * drafts it wants to write. Resolves the agent's closing summary.
 */
export async function runWithOwner(
  agent: ReturnType<typeof buildAgent>,
  task: string,
  owner: Owner,
  usage?: Usage
): Promise<string> {
  // `usage` counts every model call's tokens and cost, the subagents' too.
  const config = {
    configurable: { thread_id: randomUUID() },
    recursionLimit: MAX_STEPS,
    callbacks: usage ? [usage] : []
  };
  let input: Parameters<typeof agent.invoke>[0] = { messages: [{ role: 'user', content: task }] };

  for (;;) {
    owner.busy?.();
    const result = await agent.invoke(input, config);
    // Several rewriters can pause at once: each pause gets its own answer, by its id.
    const pending = result.__interrupt__ ?? [];
    if (pending.length === 0) {
      const last = result.messages.at(-1);
      return typeof last?.content === 'string' ? last.content : JSON.stringify(last?.content ?? '');
    }

    const answers: Record<string, unknown> = {};
    for (const { id, value } of pending) {
      const paused = value as { question?: string; actionRequests?: ActionRequest[] };
      if (paused.question) {
        answers[String(id)] = await owner.answer(paused.question);
        continue;
      }
      const decisions: Decision[] = [];
      for (const action of paused.actionRequests ?? []) {
        decisions.push(await review(action, owner));
      }
      answers[String(id)] = { decisions };
    }
    input = new Command({ resume: answers });
  }
}

/** Your choice, in the form the agent's approval step takes: approve, edit (fewer changes) or reject. */
async function review(action: ActionRequest, owner: Owner): Promise<Decision> {
  const changes = action.args.changes ?? [];
  const choice = await owner.decide({
    kind: action.args.kind ?? 'page',
    title: action.args.title ?? '',
    changes
  });

  switch (choice.type) {
    case 'all':
      return { type: 'approve' };
    case 'some': {
      const kept = changes.filter((_, index) => choice.keep.includes(index));
      if (kept.length === 0) {
        return {
          type: 'reject',
          message: 'The owner kept none of these changes. Do not try them again.'
        };
      }
      return {
        type: 'edit',
        editedAction: { name: action.name, args: { ...action.args, changes: kept } }
      };
    }
    case 'revise':
      return {
        type: 'reject',
        message:
          `Not saved yet: the owner wants changes first. Their words: "${choice.feedback}". ` +
          'Revise your changes to match, then call the same tool again with every change.'
      };
    case 'skip':
      return {
        type: 'reject',
        message: `The owner skipped this draft${choice.reason ? `: ${choice.reason}` : ''}. Do not try it again.`
      };
  }
}
```

```ts src/terminal.ts wrap theme={"dark"}
import { stdout } from 'node:process';
import { stripVTControlCharacters, styleText } from 'node:util';
import * as prompt from '@clack/prompts';
import { diffWordsWithSpace } from 'diff';
import { Marked, type Tokens } from 'marked';
import stringWidth from 'string-width';
import yoctoSpinner from 'yocto-spinner';
import type { Change } from './content.ts';
import type { Choice, Draft, Owner } from './review.ts';
import type { Usage } from './usage.ts';

/** You pressed Ctrl+C or Esc at a prompt: the run stops, and the drafts saved so far stay drafts. */
export class Stopped extends Error {}

/**
 * The person at the keyboard, in the terminal: a spinner while the agent works, with what it is
 * doing and what the run has used so far; its words as formatted text.
 */
export function terminalOwner(usage?: Usage): Owner & {
  step: (line: string) => void;
  report: (markdown: string) => void;
  failed: (error: unknown) => void;
} {
  // yocto-spinner's own dots; each frame redraws in one synchronized write, so it never flickers.
  const spin = yoctoSpinner({ stream: stdout });
  // Like yocto-spinner, only a terminal (not TERM=dumb, outside CI) gets live updates; anywhere
  // else each one would print a new line, so output sent to a file gets the start and the steps.
  const live = Boolean(stdout.isTTY) && process.env.TERM !== 'dumb' && !('CI' in process.env);
  let spinning = false;
  let started = 0;
  let clock: NodeJS.Timeout | undefined;
  let steps: string[] = [];
  let doing = 'The agent is working';

  // What the agent is doing, then the tokens, cost and time so far: one line, never wrapped.
  // On a narrow terminal the tokens and cost go first, then the time, before the task is cut.
  const line = () => {
    const seconds = Math.floor((Date.now() - started) / 1000);
    const time = seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
    const spent = usage && `${tokens(usage)} · $${usage.cost.toFixed(4)}`;
    const width = (stdout.columns || 80) - 3;
    const tail =
      [[spent, time], [time]]
        .map((parts) => parts.filter(Boolean).join(' · '))
        .find((text) => width - stringWidth(text) - 2 >= Math.min(stringWidth(doing), 20)) ?? '';
    const room = tail ? width - stringWidth(tail) - 2 : width;
    return tail ? `${fit(doing, room)}  ${styleText('dim', tail)}` : fit(doing, room);
  };
  const withUsage = (text: string) =>
    usage ? `${text}  ${styleText('dim', `${tokens(usage)} · $${usage.cost.toFixed(4)}`)}` : text;
  const refresh = () => {
    if (spinning && live) {
      spin.text = line();
    }
  };
  if (usage) {
    usage.onCall = refresh;
  }

  // The spinner sits on the run's guide line, like the rest of the terminal.
  const startSpinner = () => {
    started = Date.now();
    spinning = true;
    stdout.write(`${styleText('gray', prompt.S_BAR)}\n`);
    spin.start(line());
    if (live) {
      clock = setInterval(refresh, 1000);
    }
  };
  const stopSpinner = (finish: () => void) => {
    clearInterval(clock);
    spin.stop();
    spinning = false;
    finish();
  };

  // What the agent did since you last heard from it, as one dim line.
  const settle = () => {
    if (spinning) {
      const done = withUsage(styleText('dim', steps.join(' · ') || 'Done'));
      stopSpinner(() => prompt.log.step(done, { spacing: 0 }));
    }
    steps = [];
  };

  return {
    busy() {
      if (!spinning) {
        doing = 'The agent is working';
        startSpinner();
      }
    },
    step(text) {
      doing = text.replace(/\.$/, '');
      steps.push(doing);
      refresh();
    },
    async answer(question) {
      settle();
      say(markdown(question), 'cyan');
      return ask(() =>
        prompt.text({ message: 'Your answer', placeholder: 'Type, then press Enter' })
      );
    },
    async decide(draft) {
      settle();
      say(
        [
          styleText('bold', `Save a draft of the ${draft.kind} "${draft.title}"?`),
          ...draft.changes.map((change, index) => showChange(change, index))
        ].join('\n\n'),
        'cyan'
      );
      return decide(draft);
    },
    /** Shows the run's report. */
    report(text) {
      settle();
      say(markdown(text), 'green');
    },
    /** The run stopped on an error: the spinner says so, and the error follows. */
    failed(error) {
      if (spinning) {
        stopSpinner(() => prompt.log.error('The run stopped', { spacing: 0 }));
      }
      prompt.log.error(error instanceof Error ? error.message : String(error));
    }
  };
}

/** "183,204 tokens": read and written together. */
function tokens(usage: Usage): string {
  return `${new Intl.NumberFormat('en-US').format(usage.inputTokens + usage.outputTokens)} tokens`;
}

/**
 * Text cut to a number of terminal cells, never through a character (an emoji counts as two).
 * yocto-spinner counts a line in UTF-16 code units, which can outnumber its cells (a family emoji
 * is 8), so the cut keeps both within the limit.
 */
function fit(text: string, cells: number): string {
  const size = (part: string) => Math.max(stringWidth(part), part.length);
  if (size(text) <= cells) {
    return text;
  }
  let cut = '';
  for (const { segment } of new Intl.Segmenter().segment(text)) {
    if (size(`${cut}${segment}…`) > cells) {
      break;
    }
    cut += segment;
  }
  return `${cut}…`;
}

/** Prints a block beside the run's guide line, each line wrapped to the terminal's width. */
function say(text: string, color: 'cyan' | 'green') {
  prompt.log.message(wrap(text, (stdout.columns || 80) - 4), { symbol: styleText(color, '◆') });
}

// The terminal's color and style codes (ESC [ … m), as styleText writes them.
const STYLE_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*m`, 'g');

/** Wraps each line at spaces, keeping its indent on the lines it wraps onto. Colors don't count. */
export function wrap(text: string, width: number): string {
  const visible = (line: string) => stripVTControlCharacters(line).length;
  return text
    .split('\n')
    .map((line) => {
      // A wrapped line sits under the text, past any list number or bullet.
      const lead =
        /^( *)((\d+\.|•|before|after ) )?/.exec(stripVTControlCharacters(line))?.[0] ?? '';
      const indent = ' '.repeat(lead.length);
      const words = line.trimStart().split(' ');
      const lines: string[] = [];
      let current = line.slice(0, line.length - line.trimStart().length) + (words.shift() ?? '');
      for (const word of words) {
        if (visible(`${current} ${word}`) > width && current.trim()) {
          // The colors in force so far carry onto the next line: the guide line resets them.
          const colors = (lines.join('') + current).match(STYLE_CODES)?.join('') ?? '';
          lines.push(`${current}\x1b[0m`);
          current = indent + colors + word;
        } else {
          current = `${current} ${word}`;
        }
      }
      lines.push(current);
      return lines.join('\n');
    })
    .join('\n');
}

/** Starts and ends the run's frame in the terminal. */
export const intro = (title: string) => prompt.intro(styleText(['bgCyan', 'black'], ` ${title} `));
export const outro = (line: string) => prompt.outro(line);
export const note = (line: string) => prompt.log.info(line);

async function decide(draft: Draft): Promise<Choice> {
  const many = draft.changes.length > 1;
  const choice = await ask(() =>
    prompt.select({
      message: 'Your call',
      options: [
        { value: 'all', label: many ? `Save all ${draft.changes.length} changes` : 'Save it' },
        ...(many ? [{ value: 'some', label: 'Pick which changes to keep' }] : []),
        { value: 'revise', label: 'Tell the agent what to change', hint: 'it tries again' },
        { value: 'skip', label: 'Skip this one', hint: 'nothing is saved' }
      ]
    })
  );
  if (choice === 'some') {
    const keep = await ask(() =>
      prompt.multiselect({
        message: 'Keep these (Space to toggle, Enter to save)',
        options: draft.changes.map((change, index) => ({
          value: index,
          label: `${index + 1}. ${plain(change.after)}`
        })),
        initialValues: draft.changes.map((_, index) => index),
        required: false
      })
    );
    return { type: 'some', keep };
  }
  if (choice === 'revise') {
    return {
      type: 'revise',
      feedback: await ask(() =>
        prompt.text({
          message: 'What should change?',
          validate: (value) => (value?.trim() ? undefined : 'Say what should change.')
        })
      )
    };
  }
  if (choice === 'skip') {
    return {
      type: 'skip',
      reason: await ask(() =>
        prompt.text({ message: 'Why? (optional, the agent reads it)', defaultValue: '' })
      )
    };
  }
  return { type: 'all' };
}

/** Waits for one prompt; Ctrl+C or Esc stops the run. */
async function ask<T>(show: () => Promise<T | symbol>): Promise<Exclude<T, symbol>> {
  const value = await show();
  if (prompt.isCancel(value)) {
    throw new Stopped('You stopped the run.');
  }
  return value as Exclude<T, symbol>;
}

/**
 * One change, numbered: the text before and after, whole, with the words that change marked (red
 * and struck through before, green after). Still readable with the colors gone, pasted elsewhere.
 */
export function showChange(change: Change, index: number): string {
  const parts = diffWordsWithSpace(change.before, change.after);
  const kept = parts.filter((part) => !part.added && !part.removed);
  const label = `${styleText(['bold', 'cyan'], `${index + 1}.`)} ${styleText('dim', change.path)}`;
  // Mostly rewritten: the old text in red and the new in green, with no marks to read through.
  if (
    kept
      .map((part) => part.value)
      .join('')
      .trim().length <
    change.after.length / 2
  ) {
    return [
      label,
      `   ${styleText('dim', 'before')} ${styleText('red', change.before)}`,
      `   ${styleText('dim', 'after ')} ${styleText('green', change.after)}`
    ].join('\n');
  }
  const side = (drop: 'added' | 'removed', mark: Parameters<typeof styleText>[0]) =>
    parts
      .filter((part) => !part[drop])
      .map((part) =>
        part.added || part.removed ? styleText(mark, part.value) : dimTags(part.value)
      )
      .join('');
  return [
    label,
    `   ${styleText('dim', 'before')} ${side('added', ['red', 'strikethrough'])}`,
    `   ${styleText('dim', 'after ')} ${side('removed', ['green', 'bold'])}`
  ].join('\n');
}

/** Rich text's HTML tags, dimmed, so the words stand out. */
const dimTags = (text: string) => text.replace(/<[^>]+>/g, (tag) => styleText('dim', tag));

/** Text with its HTML tags taken out, on one line, for a short label. */
function plain(text: string, max = 70): string {
  const flat = text
    .replace(/<[^>]+>/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
}

// The agent writes Markdown. This renders it for the terminal, with a blank line after every
// paragraph and every list item, so a long answer reads as separate blocks, not a wall of text.
const terminal = new Marked({
  renderer: {
    heading({ tokens }) {
      return `${styleText(['bold', 'cyan'], this.parser.parseInline(tokens))}\n\n`;
    },
    paragraph({ tokens }) {
      return `${this.parser.parseInline(tokens)}\n\n`;
    },
    list(list: Tokens.List) {
      const start = typeof list.start === 'number' ? list.start : 1;
      return list.items
        .map((item, index) => {
          const bullet = list.ordered ? `${start + index}.` : '•';
          const body = this.parser.parse(item.tokens).trim();
          // Lines under the first are indented to sit under its text.
          const pad = ' '.repeat(bullet.length + 1);
          return `${styleText('cyan', bullet)} ${body.replace(/\n/g, `\n${pad}`)}\n\n`;
        })
        .join('');
    },
    text(token) {
      return 'tokens' in token && token.tokens ? this.parser.parseInline(token.tokens) : token.text;
    },
    strong({ tokens }) {
      return styleText('bold', this.parser.parseInline(tokens));
    },
    em({ tokens }) {
      return styleText('italic', this.parser.parseInline(tokens));
    },
    codespan({ text }) {
      return styleText('cyan', text);
    },
    code({ text }) {
      return `${styleText('cyan', text)}\n\n`;
    },
    blockquote({ tokens }) {
      return `${styleText('dim', `│ ${this.parser.parse(tokens).trim().replace(/\n/g, '\n│ ')}`)}\n\n`;
    },
    link({ href, tokens }) {
      const text = this.parser.parseInline(tokens);
      return text === href
        ? styleText('underline', href)
        : `${text} ${styleText('dim', `(${href})`)}`;
    },
    hr() {
      return `${styleText('dim', '─'.repeat(Math.min(stdout.columns || 60, 60)))}\n\n`;
    },
    br() {
      return '\n';
    },
    html({ text }) {
      return text;
    }
  }
});

/** The agent's Markdown, formatted for the terminal. */
export function markdown(text: string): string {
  return String(terminal.parse(text, { async: false })).trim();
}
```

```ts src/usage.ts wrap theme={"dark"}
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
import { AIMessage } from '@langchain/core/messages';
import type { ChatGeneration, LLMResult } from '@langchain/core/outputs';

/**
 * Counts every model call in a run, the subagents' too: tokens read and written, and what
 * OpenRouter charged. Pass it in the run's `callbacks`; LangChain calls handleLLMEnd after each call.
 */
export class Usage extends BaseCallbackHandler {
  name = 'usage';
  calls = 0;
  inputTokens = 0;
  outputTokens = 0;
  /** In US dollars, from OpenRouter's own answer (`usage.cost`), never estimated from a price list. */
  cost = 0;
  /** Calls whose answer carried no cost (a streamed answer, or a provider that leaves it out). */
  uncosted = 0;
  /** Called after each model call, e.g. to refresh a running total on screen. */
  onCall?: () => void;

  override handleLLMEnd(output: LLMResult) {
    this.calls += 1;
    const message = (output.generations[0]?.[0] as ChatGeneration | undefined)?.message;
    const usage = AIMessage.isInstance(message) ? message.usage_metadata : undefined;
    this.inputTokens += usage?.input_tokens ?? 0;
    this.outputTokens += usage?.output_tokens ?? 0;
    const cost = output.llmOutput?.tokenUsage?.cost;
    if (typeof cost === 'number') {
      this.cost += cost;
    } else {
      this.uncosted += 1;
    }
    this.onCall?.();
  }

  /** One line: "42 model calls · 183,204 tokens in · 9,876 out · $0.0412". */
  line(): string {
    const count = new Intl.NumberFormat('en-US');
    const parts = [
      `${count.format(this.calls)} model call${this.calls === 1 ? '' : 's'}`,
      `${count.format(this.inputTokens)} tokens in`,
      `${count.format(this.outputTokens)} out`,
      `$${this.cost.toFixed(4)}`
    ];
    const line = parts.join(' · ');
    return this.uncosted > 0
      ? `${line} (${this.uncosted} call${this.uncosted === 1 ? '' : 's'} without a cost)`
      : line;
  }

  /** The run's usage, as a Markdown section for the report and its email. */
  markdown(): string {
    return `## Usage\n\n${this.line()}. The cost is what OpenRouter charged for this run.`;
  }
}
```

```ts src/report.ts wrap theme={"dark"}
import type { SiteClient } from '@fetchski/attract-cms-sdk';
import { previewLink } from './content.ts';
import type { Usage } from './usage.ts';

/** A draft the run saved, and the private link that previews it. */
export interface WaitingDraft {
  title: string;
  link: string;
}

/** Every page the run saved a draft of (id → title), each with its preview link. */
export async function draftsWaiting(
  site: SiteClient,
  saved: Map<string, string>
): Promise<WaitingDraft[]> {
  const drafts: WaitingDraft[] = [];
  for (const [id, title] of saved) {
    drafts.push({ title, link: await previewLink(site, id) });
  }
  return drafts;
}

/**
 * The run's report, in Markdown: a heading, the agent's summary, the drafts waiting for you, then
 * what the run used (tokens and cost).
 */
export function reportMarkdown(
  summary: string,
  drafts: readonly WaitingDraft[],
  usage?: Usage,
  date = new Date()
) {
  // The site's host for the heading; a missing or mistyped URL still gets a report that says why.
  const site = URL.parse(process.env.FETCHSKI_ATTRACT_URL ?? '')?.host || 'your site';
  const lines = [
    `# One design on ${site}, ${date.toLocaleDateString('en-CA')}`,
    '',
    summary.trim()
  ];
  if (drafts.length > 0) {
    lines.push('', '## Drafts waiting for you', '');
    lines.push('Publish each one in the CMS when it looks right.', '');
    for (const draft of drafts) {
      lines.push(`- "${draft.title}", preview: ${draft.link}`);
    }
  }
  if (usage) {
    lines.push('', usage.markdown());
  }
  return `${lines.join('\n')}\n`;
}
```

```ts src/main.ts wrap theme={"dark"}
import { createSiteClient } from '@fetchski/attract-cms-sdk';
import { buildAgent, modelFromEnv } from './agent.ts';
import { draftsWaiting, reportMarkdown } from './report.ts';
import { runWithOwner } from './review.ts';
import { intro, outro, Stopped, terminalOwner } from './terminal.ts';
import { newRun } from './tools.ts';
import { Usage } from './usage.ts';

// FETCHSKI_ATTRACT_URL, FETCHSKI_ATTRACT_API_KEY and FETCHSKI_ATTRACT_SIGNING_SECRET, from .env
const site = createSiteClient();

const run = newRun();
const usage = new Usage();
const owner = terminalOwner(usage);
const agent = buildAgent(site, modelFromEnv(), run, owner.step);

intro('One design');
let summary: string;
try {
  summary = await runWithOwner(
    agent,
    'Check this whole website against its own design and against WCAG contrast, and fix what we can with me.',
    owner,
    usage
  );
} catch (error) {
  if (!(error instanceof Stopped)) {
    owner.failed(error);
    throw error;
  }
  // Ctrl+C at a prompt: the drafts saved so far stay drafts, and the report lists them.
  summary = 'You stopped the run before it finished.';
}

const report = reportMarkdown(summary, await draftsWaiting(site, run.saved), usage);
owner.report(report);
outro(`Done: ${run.saved.size} draft${run.saved.size === 1 ? '' : 's'} saved · ${usage.line()}`);
```

```ts test/agent.test.ts wrap theme={"dark"}
import { deepStrictEqual, ok, strictEqual } from 'node:assert';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { factories, fakeSite } from '@fetchski/attract-cms-sdk/testing';
import { AIMessage } from '@langchain/core/messages';
import type { LLMResult } from '@langchain/core/outputs';
import { fakeModel } from 'langchain';
import { buildAgent } from '../src/agent.ts';
import { checkContrast, contrastRatio, resolvePalette } from '../src/design.ts';
import { type Choice, type Draft, nobody, type Owner, runWithOwner } from '../src/review.ts';
import { newRun, siteTools } from '../src/tools.ts';
import { Usage } from '../src/usage.ts';

const HOME = 'page-home:en:draft';
const ABOUT = 'page-about:en:draft';
const MONDAY = '2026-10-26T09:00:00.000Z';
const TUESDAY = '2026-10-27T09:00:00.000Z';
// A fresh memory folder, per test.
const memoryFolder = () => mkdtempSync(join(tmpdir(), 'memories-'));

function area(widget: Record<string, unknown>) {
  return { metaType: 'area', items: [{ _id: 'w1', metaType: 'widget', ...widget }] };
}

// Home follows the palette. About was built later: a gray heading on its own white band, and pill buttons.
const PAGES: Record<string, ReturnType<typeof factories.Page>> = {
  [HOME]: factories.Page({
    id: HOME,
    title: 'Home',
    sections: area({
      type: 'intro',
      introBg: 'raised',
      introEyebrowColor: '--fetchski-mint',
      introCtaRadius: 'slight'
    })
  }),
  [ABOUT]: factories.Page({
    id: ABOUT,
    title: 'About us',
    sections: area({
      type: 'media-text',
      mediaTextBg: 'custom',
      mediaTextBgCustom: '#ffffff',
      mediaTextHeadingColor: '#777777',
      mediaTextCtaRadius: 'pill'
    })
  })
};

function siteWithTwoDesigns(updated: Record<string, string> = { [HOME]: MONDAY, [ABOUT]: MONDAY }) {
  return fakeSite({
    'pages.listPages': factories.ListPagesResponse({
      results: Object.entries(updated).map(([id, updatedAt]) => ({
        id,
        title: String(PAGES[id]?.title),
        type: 'default-page',
        updatedAt: new Date(updatedAt)
      }))
    }),
    'pages.getPage': (args) => PAGES[String(args.id)],
    'globalContent.listGlobalContent': factories.GlobalList({
      results: [
        {
          paletteSecondary: '#2dd4bf',
          fontDisplaySource: 'curated',
          fontDisplayCurated: 'fraunces'
        }
      ],
      pages: 1,
      currentPage: 1
    })
  });
}

const HEADING_FIX = {
  path: 'sections.items.0.mediaTextHeadingColor',
  before: '#777777',
  after: '--fetchski-ink'
};
const RADIUS_FIX = { path: 'sections.items.0.mediaTextCtaRadius', before: 'pill', after: 'slight' };

// The moves a real model would make, scripted: the main agent's, each subagent's, then the main agent's summary.
function scriptedModel() {
  return (
    fakeModel()
      .respondWithTools([
        { name: 'read_site_design', args: {} },
        { name: 'list_pages', args: {} }
      ])
      .respondWithTools([
        {
          name: 'task',
          args: {
            subagent_type: 'style-collector',
            description: `Collect ${HOME} "Home" into /memories/pages/page-home.md and ${ABOUT} "About us" into /memories/pages/page-about.md.`
          }
        }
      ])
      // The style-collector subagent:
      .respondWithTools([
        { name: 'read_page_style', args: { id: HOME } },
        { name: 'read_page_style', args: { id: ABOUT } }
      ])
      .respondWithTools([
        {
          name: 'write_file',
          args: {
            file_path: '/memories/pages/page-home.md',
            content: `# Home\nid: ${HOME}\nupdated: ${MONDAY}\n## sections.items.0 (intro)\n- background: dark raised, light raised`
          }
        },
        {
          name: 'write_file',
          args: {
            file_path: '/memories/pages/page-about.md',
            content: `# About us\nid: ${ABOUT}\nupdated: ${MONDAY}\n## sections.items.0 (media-text)\n- background: dark #ffffff, light #ffffff\n- mediaTextHeadingColor: dark #777777, light #777777`
          }
        }
      ])
      .respond(new AIMessage('/memories/pages/page-home.md, /memories/pages/page-about.md'))
      // The main agent again:
      .respondWithTools([
        {
          name: 'task',
          args: {
            subagent_type: 'contrast-checker',
            description:
              'Check /memories/pages/page-home.md and /memories/pages/page-about.md. Findings file: /findings/contrast-1.md'
          }
        }
      ])
      // The contrast-checker subagent:
      .respondWithTools([
        { name: 'read_file', args: { file_path: '/memories/pages/page-about.md' } }
      ])
      .respondWithTools([
        {
          name: 'check_contrast',
          args: { text: '#777777', background: '#ffffff', theme: 'dark', size: 'normal' }
        }
      ])
      .respondWithTools([
        {
          name: 'write_file',
          args: {
            file_path: '/findings/contrast-1.md',
            content: '- About us | sections.items.0 mediaTextHeadingColor | dark | 4.47, needs 4.5'
          }
        }
      ])
      .respond(new AIMessage('/findings/contrast-1.md'))
      // The main agent again:
      .respondWithTools([
        {
          name: 'write_file',
          args: {
            file_path: '/memories/site.md',
            content: '# Site design\n- Buttons: slight corners'
          }
        }
      ])
      .respondWithTools([
        {
          name: 'ask_owner',
          args: { question: 'Home has slight button corners, About us has pill. Which is yours?' }
        }
      ])
      .respondWithTools([
        {
          name: 'save_style_changes',
          args: { id: ABOUT, title: 'About us', changes: [HEADING_FIX, RADIUS_FIX] }
        }
      ])
      .respond(
        new AIMessage('1 contrast failure and 1 outlier on About us, both fixed in a draft.')
      )
  );
}

// The person at the keyboard, scripted: their answers to questions, then their call on each draft.
function scriptedOwner(answers: string[], choices: Choice[]): Owner & { drafts: Draft[] } {
  const drafts: Draft[] = [];
  return {
    drafts,
    answer: async () => answers.shift() ?? '',
    decide: async (draft) => {
      drafts.push(draft);
      return choices.shift() ?? { type: 'skip', reason: '' };
    }
  };
}

test('a first run collects every page into memory, and saves an approved fix as a draft', async () => {
  const site = siteWithTwoDesigns();
  const folder = memoryFolder();
  const run = newRun(folder);
  const owner = scriptedOwner(['Slight'], [{ type: 'all' }]);

  const summary = await runWithOwner(
    buildAgent(site, scriptedModel(), run, () => {}),
    'Check this whole website.',
    owner
  );
  strictEqual(summary, '1 contrast failure and 1 outlier on About us, both fixed in a draft.');
  site.assertCalled('pages.updatePage', (args) => {
    const section = (args.sections as { items: Record<string, unknown>[] }).items[0];
    return (
      args.id === ABOUT &&
      section?.mediaTextHeadingColor === '--fetchski-ink' &&
      section.mediaTextCtaRadius === 'slight'
    );
  });
  deepStrictEqual([...run.saved], [[ABOUT, 'About us']]);
  deepStrictEqual(
    owner.drafts.map((draft) => draft.title),
    ['About us']
  );
  ok(existsSync(join(folder, 'pages', 'page-home.md')));
  ok(existsSync(join(folder, 'site.md')));

  // The next run finds both pages unchanged, from their memory files alone.
  const next = newRun(folder);
  await siteTools(site, next, () => {}).listPages.invoke({});
  deepStrictEqual(
    next.pages.map((page) => page.status),
    ['unchanged', 'unchanged']
  );
});

test('a fix the owner skips is never written', async () => {
  const site = siteWithTwoDesigns();

  await runWithOwner(
    buildAgent(site, scriptedModel(), newRun(memoryFolder()), () => {}),
    'Check this whole website.',
    scriptedOwner(['Slight'], [{ type: 'skip', reason: 'That gray is on purpose' }])
  );

  site.assertNotCalled('pages.updatePage');
});

test('a report-only second run collects only the changed page, and never asks or writes', async () => {
  // The last run scanned Home, About us and a page since deleted. Since then, only About us changed.
  const folder = memoryFolder();
  mkdirSync(join(folder, 'pages'));
  for (const name of ['page-home', 'page-about', 'page-old']) {
    writeFileSync(join(folder, 'pages', `${name}.md`), `# ${name}\nupdated: ${MONDAY}\n`);
  }
  writeFileSync(
    join(folder, 'site.md'),
    '# Site design\n- Buttons: slight corners, the owner chose'
  );
  const site = siteWithTwoDesigns({ [HOME]: MONDAY, [ABOUT]: TUESDAY });
  const run = newRun(folder);
  const model = fakeModel()
    .respondWithTools([{ name: 'list_pages', args: {} }])
    .respondWithTools([
      {
        name: 'task',
        args: {
          subagent_type: 'style-collector',
          description: `Collect ${ABOUT} "About us" into /memories/pages/page-about.md.`
        }
      }
    ])
    .respondWithTools([{ name: 'read_page_style', args: { id: ABOUT } }])
    .respondWithTools([
      {
        name: 'write_file',
        args: {
          file_path: '/memories/pages/page-about.md',
          content: `# About us, as it is now\nupdated: ${TUESDAY}\n`
        }
      }
    ])
    .respond(new AIMessage('/memories/pages/page-about.md'))
    .respond(new AIMessage('## Contrast\nNo failures.'));

  const summary = await runWithOwner(
    buildAgent(site, model, run, () => {}, { reportOnly: true }),
    'Check this whole website, and report what you find.',
    nobody
  );
  strictEqual(summary, '## Contrast\nNo failures.');
  deepStrictEqual(
    run.pages.map((page) => [page.title, page.status]),
    [
      ['Home', 'unchanged'],
      ['About us', 'changed']
    ]
  );
  site.assertCalledTimes('pages.getPage', 1);
  site.assertCalled('pages.getPage', (args) => args.id === ABOUT);
  site.assertNotCalled('pages.updatePage');
  strictEqual(
    readFileSync(join(folder, 'pages', 'page-about.md'), 'utf8'),
    `# About us, as it is now\nupdated: ${TUESDAY}\n`
  );
  strictEqual(existsSync(join(folder, 'pages', 'page-old.md')), false);
  // site.md, with the owner's choice, was in the agent's instructions from the start.
  const instructions = model.calls[0]?.messages.map((message) => message.text).join('\n') ?? '';
  ok(instructions.includes('Buttons: slight corners, the owner chose'));
});

test('check_contrast measures with the WCAG formula, in code', async () => {
  const palette = resolvePalette({});
  strictEqual(contrastRatio('#000000', '#ffffff'), 21);
  strictEqual(Math.round(contrastRatio('#777777', '#ffffff') * 100) / 100, 4.48);
  deepStrictEqual(checkContrast('#777777', '#ffffff', 'light', 'normal', palette), {
    theme: 'light',
    text: '#777777',
    background: '#ffffff',
    ratio: 4.47,
    needs: 4.5,
    passes: false
  });
  const large = checkContrast('#777777', '#ffffff', 'light', 'large', palette);
  ok('passes' in large && large.passes);

  // Through the tool: palette tokens resolve with the site's own palette, in the theme asked.
  const { checkContrast: tool } = siteTools(siteWithTwoDesigns(), newRun(memoryFolder()), () => {});
  const mint = JSON.parse(
    await tool.invoke({
      text: '--fetchski-mint',
      background: 'page',
      theme: 'dark',
      size: 'normal'
    })
  );
  deepStrictEqual([mint.text, mint.background, mint.passes], ['#2dd4bf', '#09090b', true]);
  const overVideo = JSON.parse(
    await tool.invoke({
      text: '#ffffff',
      background: 'image or video',
      theme: 'dark',
      size: 'large'
    })
  );
  deepStrictEqual(overVideo, {
    theme: 'dark',
    measured: false,
    why: 'background: the background is an image or video'
  });
});

test('usage adds up tokens and the cost OpenRouter reports for each call', () => {
  const usage = new Usage();
  const call = (input: number, output: number, cost?: number) =>
    usage.handleLLMEnd({
      generations: [
        [
          {
            text: '',
            message: new AIMessage({
              content: '',
              usage_metadata: {
                input_tokens: input,
                output_tokens: output,
                total_tokens: input + output
              }
            })
          }
        ]
      ],
      // OpenRouter's own `usage` object, as ChatOpenRouter passes it on.
      llmOutput: { tokenUsage: cost === undefined ? {} : { cost } }
    } as unknown as LLMResult);
  call(1000, 200, 0.0012);
  call(3000, 50, 0.0021);
  call(10, 5);

  strictEqual(
    usage.line(),
    '3 model calls · 4,010 tokens in · 255 out · $0.0033 (1 call without a cost)'
  );
  ok(usage.markdown().startsWith('## Usage\n\n3 model calls'));
});
```

```json package.json theme={"dark"}
{
  "name": "one-design",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=26.12.1"
  },
  "scripts": {
    "start": "node --env-file=.env src/main.ts",
    "test": "node --test 'test/*.test.ts'",
    "typecheck": "tsc"
  },
  "dependencies": {
    "@clack/prompts": "^1.8.1",
    "@fetchski/attract-cms-sdk": "latest",
    "@langchain/core": "^1.3.1",
    "@langchain/langgraph": "^1.5.3",
    "@langchain/openrouter": "^0.4.21",
    "deepagents": "^1.16.0",
    "diff": "^9.0.0",
    "langchain": "^1.6.0",
    "marked": "^18.1.2",
    "string-width": "^8.3.0",
    "yocto-spinner": "^1.2.2",
    "zod": "^4.6.7"
  },
  "devDependencies": {
    "@types/node": "^26.6.5",
    "typescript": "^7.0.2"
  }
}
```

```json tsconfig.json theme={"dark"}
{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
```

```ini .env theme={"dark"}
# Your site, and the API key from "Make the API key"
FETCHSKI_ATTRACT_URL=https://acme.example
FETCHSKI_ATTRACT_API_KEY=fsk_acme_…
FETCHSKI_ATTRACT_SIGNING_SECRET=fss_…

# OpenRouter: your key, and the model the agent thinks with. Any model id from openrouter.ai/models
# that supports tools works; a ~provider/family-latest alias always gets that family's newest model.
OPENROUTER_API_KEY=sk-or-v1-…
OPENROUTER_MODEL=~google/gemini-flash-latest
# How freely it words things (0 = the same way every time), and the most it writes in one answer
OPENROUTER_TEMPERATURE=0
OPENROUTER_MAX_TOKENS=16000

# Ceilings on one run: pages read, and steps taken (each model call or tool call is one)
MAX_PAGES=80
AGENT_MAX_STEPS=400
```

```ini .npmrc theme={"dark"}
@fetchski:registry=https://acme.example/api/v1/sdk/npm/
//acme.example/api/v1/sdk/npm/:_authToken=fsk_acme_…
```

```text .gitignore theme={"dark"}
node_modules
.env
.npmrc
```

## 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](https://docs.langchain.com/oss/javascript/deepagents/overview).
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](https://docs.langchain.com/oss/javascript/deepagents/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.

```mermaid theme={"dark"}
flowchart LR
    A[Site design<br/>and every page] --> B{New or changed<br/>since last run?}
    B -- yes --> C[Style collectors<br/>a batch at a time]
    B -- no --> D[Its memory file]
    C --> D
    D --> E[Contrast checker<br/>WCAG math in code]
    D --> F[Compared with<br/>site.md]
    E --> G[A fix per page]
    F --> G
    G -- you approve --> H[Saved as a draft]
    G -- change this --> G
    G -- you skip --> I[Nothing written]
```

> [!NOTE]
>
> **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.

> [!NOTE]
>
> **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](https://openrouter.ai) account with credit**, and a key made at
>   [openrouter.ai/settings/keys](https://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](#run-it-every-week)).
> * **Time:** about 20 minutes to set up; a first run takes 10 to 20 minutes plus your answers.

## Make the API key

**1. 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**.

**2. Name it**

**Label:** `One design`.

**3. 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:

| Tick | Scope | Why |
| - | - | - |
| **Pages: write drafts** | `content:pages:write` | Lists and reads the pages' drafts (with when each last changed), saves the fixes to them, and makes their preview links. |
| **Read site settings** | `global:read` | Reads Site design: your palette in both themes, your fonts and the site-wide custom CSS. It can't change them. |

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.

**4. 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.

**5. 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

**1. Make the project folder**

```bash Terminal theme={"dark"}
mkdir -p one-design/src one-design/test
cd one-design
```

**Why:** every command and file below lives in this folder.

**You should see:** nothing printed; `pwd` ends in `/one-design`.

**2. Add package.json**

```json package.json theme={"dark"}
{
  "name": "one-design",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=26.12.1"
  },
  "scripts": {
    "start": "node --env-file=.env src/main.ts",
    "test": "node --test 'test/*.test.ts'",
    "typecheck": "tsc"
  },
  "dependencies": {
    "@clack/prompts": "^1.8.1",
    "@fetchski/attract-cms-sdk": "latest",
    "@langchain/core": "^1.3.1",
    "@langchain/langgraph": "^1.5.3",
    "@langchain/openrouter": "^0.4.21",
    "deepagents": "^1.16.0",
    "diff": "^9.0.0",
    "langchain": "^1.6.0",
    "marked": "^18.1.2",
    "string-width": "^8.3.0",
    "yocto-spinner": "^1.2.2",
    "zod": "^4.6.7"
  },
  "devDependencies": {
    "@types/node": "^26.6.5",
    "typescript": "^7.0.2"
  }
}
```

**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`](https://bomb.sh/docs/clack/packages/prompts), [`diff`](https://github.com/kpdecker/jsdiff),
[`marked`](https://marked.js.org), [`string-width`](https://github.com/sindresorhus/string-width) and
[`yocto-spinner`](https://github.com/sindresorhus/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.

**3. 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:

```ini .npmrc theme={"dark"} theme={"dark"}
@fetchski:registry=https://acme.example/api/v1/sdk/npm/
//acme.example/api/v1/sdk/npm/:_authToken=fsk_acme_…
```

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.

> [!NOTE]
>
> **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](https://docs.fetchski-attract.com/authentication.md)).

Then keep the file out of git. Add this line to the project's `.gitignore` (make the file if
there isn't one):

```text .gitignore theme={"dark"} theme={"dark"}
.npmrc
```

> [!WARNING]
>
> **`.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`.

**4. Install**

```bash npm theme={"dark"}
npm install
```

```bash pnpm theme={"dark"}
pnpm install
```

```bash bun theme={"dark"}
bun install
```

Then check that Node finds the two packages that matter most:

```bash Terminal theme={"dark"}
node --input-type=module -e "await import('deepagents'); await import('@fetchski/attract-cms-sdk'); console.log('installed')"
```

**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`.

**5. Add tsconfig.json**

```json tsconfig.json theme={"dark"}
{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
```

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

**6. 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`.

```ini .env theme={"dark"}
# Your site, and the API key from "Make the API key"
FETCHSKI_ATTRACT_URL=https://acme.example
FETCHSKI_ATTRACT_API_KEY=fsk_acme_…
FETCHSKI_ATTRACT_SIGNING_SECRET=fss_…

# OpenRouter: your key, and the model the agent thinks with. Any model id from openrouter.ai/models
# that supports tools works; a ~provider/family-latest alias always gets that family's newest model.
OPENROUTER_API_KEY=sk-or-v1-…
OPENROUTER_MODEL=~google/gemini-flash-latest
# How freely it words things (0 = the same way every time), and the most it writes in one answer
OPENROUTER_TEMPERATURE=0
OPENROUTER_MAX_TOKENS=16000

# Ceilings on one run: pages read, and steps taken (each model call or tool call is one)
MAX_PAGES=80
AGENT_MAX_STEPS=400
```

```text .gitignore theme={"dark"}
node_modules
.env
.npmrc
```

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

| Setting | What it does |
| - | - |
| `OPENROUTER_MODEL` | The model the agent thinks with. Any model from [openrouter.ai/models](https://openrouter.ai/models) that supports tools works. `~google/gemini-flash-latest` is an alias: OpenRouter always serves the newest Gemini Flash under it, so the project never names a model that gets retired. Swap in `~anthropic/claude-sonnet-latest` or `~openai/gpt-mini-latest` the same way. |
| `OPENROUTER_TEMPERATURE` | How freely the model words things. `0` gives the same answer to the same question, which is what a check wants. |
| `OPENROUTER_MAX_TOKENS` | The most the model writes in one answer. 16000 leaves room for a long memory file and for the thinking a model does before it answers; a run's cost is capped by the key's credit limit and the step ceiling, not by this. |
| `MAX_PAGES` | The most new or changed pages one run reads. The rest wait for the next run, so a big site is covered over a few runs. |
| `AGENT_MAX_STEPS` | The most steps one run takes (each model call or tool call is one). |

Every value in `.env` is text; the code turns the numbers into numbers with `Number()`.

**7. Add src/design.ts: the contrast math**

```ts src/design.ts theme={"dark"}
/** The site's two themes. Dark is the default; visitors can switch to light. */
export type Theme = 'dark' | 'light';

/** Each palette token's color in each theme, as `#rrggbb`. */
export type Palette = Record<Theme, Record<string, string>>;

/** How big the text is: WCAG asks 4.5:1 for normal text, 3:1 for large text and for icons and borders. */
export type Size = 'normal' | 'large' | 'ui';

// The colors every site starts with, per palette token: [dark, light]. Site design → Colors
// overrides them; a field left empty there keeps these.
const BUILT_IN: Record<string, [string, string]> = {
  accent: ['#625bff', '#544dee'],
  'on-accent': ['#ffffff', '#ffffff'],
  mint: ['#34ddb3', '#0fa380'],
  coral: ['#ff8e63', '#d8551f'],
  surface: ['#09090b', '#e4e4e7'],
  raised: ['#18181b', '#d9d9de'],
  sunken: ['#0e0e11', '#cfcfd6'],
  hill: ['#121215', '#d8d8dd'],
  ink: ['#f4f4f5', '#18181b'],
  'ink-muted': ['#a1a1aa', '#52525b'],
  success: ['#34d399', '#059669'],
  warning: ['#fbbf24', '#d97706'],
  danger: ['#f87171', '#dc2626'],
  info: ['#60a5fa', '#2563eb']
};

// Site design → Colors: each field's palette token. A neutral's light color never borrows its dark one.
const PALETTE_FIELDS = {
  paletteAccent: 'accent',
  paletteOnAccent: 'on-accent',
  paletteSecondary: 'mint',
  paletteTertiary: 'coral',
  paletteLink: 'link',
  paletteSurface: 'surface',
  paletteRaised: 'raised',
  paletteSunken: 'sunken',
  paletteInk: 'ink',
  paletteInkMuted: 'ink-muted',
  paletteSuccess: 'success',
  paletteWarning: 'warning',
  paletteDanger: 'danger',
  paletteInfo: 'info'
} as const;
const NEUTRAL = new Set(['surface', 'raised', 'sunken', 'ink', 'ink-muted']);

/** The Global fields that hold the palette, both themes: `paletteAccent`, `paletteAccentLight`, … */
export const PALETTE_FIELD_NAMES = Object.keys(PALETTE_FIELDS).flatMap((field) => [
  field,
  `${field}Light`
]) as (keyof typeof PALETTE_FIELDS | `${keyof typeof PALETTE_FIELDS}Light`)[];

// The text and background pairs every page relies on: [text token, background token].
const PALETTE_PAIRS: [string, string][] = [
  ['ink', 'surface'],
  ['ink', 'raised'],
  ['ink-muted', 'surface'],
  ['on-accent', 'accent'],
  ['link', 'surface']
];

/** A color as `#rrggbb`, or null: `#rgb`, `#rrggbb`, and `#rrggbbff` (fully opaque) are read. */
function hex(value: string): string | null {
  const match = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{6}ff)$/i.exec(value.trim());
  if (!match?.[1]) {
    return null;
  }
  const digits = match[1].slice(0, 6);
  const full = digits.length === 3 ? [...digits].map((c) => c + c).join('') : digits;
  return `#${full.toLowerCase()}`;
}

/** `share` of `top` laid over `bottom`, the way a browser paints a see-through color. */
function blend(top: string, bottom: string, share: number): string {
  const channels = (color: string) =>
    [1, 3, 5].map((at) => Number.parseInt(color.slice(at, at + 2), 16));
  const [a, b] = [channels(top), channels(bottom)];
  const mixed = a.map((value, index) => Math.round(value * share + (b[index] ?? 0) * (1 - share)));
  return `#${mixed.map((value) => value.toString(16).padStart(2, '0')).join('')}`;
}

/** The palette the site renders, from Global's palette fields over the built-in colors. */
export function resolvePalette(fields: Record<string, unknown>): Palette {
  const palette: Palette = { dark: {}, light: {} };
  const set = (name: string) => (typeof fields[name] === 'string' ? hex(fields[name]) : null);
  for (const [field, token] of Object.entries(PALETTE_FIELDS)) {
    const [darkDefault, lightDefault] = BUILT_IN[token] ?? [];
    const dark = set(field) ?? darkDefault;
    const light = set(`${field}Light`) ?? (NEUTRAL.has(token) ? null : set(field)) ?? lightDefault;
    if (dark) palette.dark[token] = dark;
    if (light) palette.light[token] = light;
  }
  for (const theme of ['dark', 'light'] as const) {
    const colors = palette[theme];
    colors.link ??= colors.accent ?? '';
    colors.hill = BUILT_IN.hill?.[theme === 'dark' ? 0 : 1] ?? '';
    // The soft accent tint is the accent, see-through, over the page.
    colors['accent-soft'] = blend(
      colors.accent ?? '',
      colors.surface ?? '',
      theme === 'dark' ? 0.16 : 0.14
    );
  }
  return palette;
}

/** The palette fields this site changed: the rest are the built-in colors. */
export function customizedFields(fields: Record<string, unknown>): string[] {
  return PALETTE_FIELD_NAMES.filter(
    (name) => typeof fields[name] === 'string' && fields[name] !== ''
  );
}

/**
 * A color as the site paints it in one theme: a hex color, a palette token (`--fetchski-accent`),
 * a background preset (`surface`, `accent-soft`) or `page` (no band: the page shows through).
 * Answers `#rrggbb`, or why it can't be measured.
 */
export function colorOf(
  value: string,
  theme: Theme,
  palette: Palette
): { color: string } | { why: string } {
  const text = value.trim();
  if (!text || text === 'default') {
    return { why: 'not set: it follows the palette' };
  }
  if (text === 'image or video') {
    return { why: 'the background is an image or video' };
  }
  const token =
    /^(?:var\()?--fetchski-([a-z-]+)\)?$/.exec(text)?.[1] ?? (text === 'page' ? 'surface' : text);
  const fromPalette = palette[theme][token];
  if (fromPalette) {
    return { color: fromPalette };
  }
  const color = hex(text);
  if (color) {
    return { color };
  }
  if (/^#[0-9a-f]{8}$/i.test(text)) {
    return { why: `${text} is partly see-through, so it depends on what is behind it` };
  }
  return { why: `"${text}" is not a plain color` };
}

function channel(value: number): number {
  const c = value / 255;
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}

/** WCAG 2.2 relative luminance of a `#rrggbb` color: 0 is black, 1 is white. */
export function luminance(color: string): number {
  const n = Number.parseInt(color.slice(1), 16);
  return (
    0.2126 * channel((n >> 16) & 255) + 0.7152 * channel((n >> 8) & 255) + 0.0722 * channel(n & 255)
  );
}

/** The WCAG contrast ratio of two `#rrggbb` colors, from 1 (none) to 21 (black on white). */
export function contrastRatio(a: string, b: string): number {
  const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
  return (light + 0.05) / (dark + 0.05);
}

/** One text and background pair, checked against WCAG AA. */
export type ContrastResult =
  | {
      theme: Theme;
      text: string;
      background: string;
      ratio: number;
      needs: number;
      passes: boolean;
    }
  | { theme: Theme; measured: false; why: string };

/** Checks a text color against its background in one theme. The math is here, never the model's. */
export function checkContrast(
  text: string,
  background: string,
  theme: Theme,
  size: Size,
  palette: Palette
): ContrastResult {
  const fore = colorOf(text, theme, palette);
  const back = colorOf(background, theme, palette);
  if ('why' in fore || 'why' in back) {
    const why =
      'why' in back ? `background: ${back.why}` : `text: ${'why' in fore ? fore.why : ''}`;
    return { theme, measured: false, why };
  }
  const ratio = contrastRatio(fore.color, back.color);
  const needs = size === 'normal' ? 4.5 : 3;
  return {
    theme,
    text: fore.color,
    background: back.color,
    ratio: Math.floor(ratio * 100) / 100,
    needs,
    passes: ratio >= needs
  };
}

/** The palette's own text pairs, checked in both themes. */
export function paletteContrast(palette: Palette): (ContrastResult & { pair: string })[] {
  return (['dark', 'light'] as const).flatMap((theme) =>
    PALETTE_PAIRS.map(([text, background]) => ({
      pair: `${text} on ${background}`,
      ...checkContrast(`--fetchski-${text}`, `--fetchski-${background}`, theme, 'normal', palette)
    }))
  );
}
```

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

**8. Add src/content.ts: the SDK calls**

```ts src/content.ts theme={"dark"}
import type { SiteClient } from '@fetchski/attract-cms-sdk';
import { customizedFields, PALETTE_FIELD_NAMES, type Palette, resolvePalette } from './design.ts';

/** A page, and when its draft last changed. */
export interface PageRow {
  id: string; // the draft's id, `…:en:draft`
  title: string;
  updatedAt: string;
}

/** A color field in both themes, as stored: '' when it isn't set (the section's default, from the palette). */
export interface ColorField {
  field: string;
  dark: string;
  light: string;
}

/** One section's look: where it sits, its background in each theme, its colors, its other settings, its custom CSS. */
export interface SectionStyle {
  path: string;
  type: string;
  background: { dark: string; light: string } | null;
  colors: ColorField[];
  settings: Record<string, unknown>;
  customCss: string;
}

/** One change to a section's setting: the value at `path` goes from `before` to `after`. */
export interface Change {
  path: string;
  before: string;
  after: string;
}

// The page fields that hold sections. Fixes are made inside these only.
const AREAS = ['sections', 'aboveSections', 'belowSections'] as const;

// Settings that are part of a section's look (colors and backgrounds are read on their own).
const SETTING = /variant|radius|shape|style|size|weight|animation|width|font/i;
const COLOR = /Color$/;
const BACKGROUND = /Bg(Light)?(Custom)?$/;

/** Every page on the site, as drafts: the latest version, published or not. */
export async function listPages(site: SiteClient): Promise<PageRow[]> {
  const pages = await site
    .pages()
    .listPagesPick(['title', 'type', 'updatedAt'], { aposMode: 'draft' });
  return pages
    .filter((page) => page.type !== '@apostrophecms/archive-page')
    .map((page) => ({ id: page.id, title: String(page.title), updatedAt: String(page.updatedAt) }));
}

/** The site's design from Site design: the palette in both themes, the fonts and the site-wide custom CSS. */
export async function readSiteDesign(site: SiteClient) {
  const fonts = ['Display', 'Body', 'Accent'] as const;
  const fontFields = fonts.flatMap((slot) => [
    `font${slot}Source`,
    `font${slot}Curated`,
    `font${slot}CdnFamily`
  ]) as `font${(typeof fonts)[number]}${'Source' | 'Curated' | 'CdnFamily'}`[];
  const [global = {}] = await site
    .globalContent()
    .listGlobalContentPick([...PALETTE_FIELD_NAMES, ...fontFields, 'designCss']);
  const fields = global as Record<string, unknown>;
  const font = (slot: string) => {
    const source = String(fields[`font${slot}Source`] ?? 'default');
    if (source === 'curated') return String(fields[`font${slot}Curated`]);
    if (source === 'cdn') return String(fields[`font${slot}CdnFamily`]);
    return source === 'upload' ? 'uploaded font files' : 'the built-in font';
  };
  return {
    palette: resolvePalette(fields) satisfies Palette,
    customized: customizedFields(fields),
    fonts: { display: font('Display'), body: font('Body'), accent: font('Accent') },
    customCss: cssOf(fields.designCss)
  };
}

/** A custom CSS field's CSS: the site stores `{ css }`. */
function cssOf(value: unknown): string {
  if (typeof value === 'string') return value;
  return value && typeof value === 'object' ? String((value as { css?: unknown }).css ?? '') : '';
}

/** A section's background in each theme: a palette preset, a hex color, `page` (none) or `image or video`. */
function backgroundOf(widget: Record<string, unknown>): SectionStyle['background'] {
  for (const [key, value] of Object.entries(widget)) {
    if (/BackgroundType$/.test(key) && (value === 'image' || value === 'video')) {
      return { dark: 'image or video', light: 'image or video' };
    }
  }
  const select = Object.keys(widget).find((key) => key.endsWith('Bg'));
  if (!select) {
    return null;
  }
  const pick = (choice: unknown, custom: unknown) =>
    choice === 'custom'
      ? String(custom ?? '')
      : !choice || choice === 'transparent'
        ? 'page'
        : String(choice);
  const dark = pick(widget[select], widget[`${select}Custom`]);
  const lightChoice = widget[`${select}Light`];
  const light =
    !lightChoice || lightChoice === 'inherit'
      ? dark
      : pick(lightChoice, widget[`${select}LightCustom`]);
  return { dark, light };
}

/** The look of one widget's own fields: its colors (with their light twins) and its other settings. */
function lookOf(fields: Record<string, unknown>) {
  const colors: ColorField[] = [];
  const settings: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(fields)) {
    if (key.startsWith('_') || value === '' || value === null || value === undefined) continue;
    const field = key.replace(/Light$/, '');
    if (COLOR.test(field)) {
      if (!colors.some((color) => color.field === field)) {
        const dark = String(fields[field] ?? '');
        colors.push({ field, dark, light: String(fields[`${field}Light`] || dark) });
      }
    } else if (SETTING.test(key) && !BACKGROUND.test(key)) {
      if (typeof value !== 'object' || (Array.isArray(value) && value.length > 0)) {
        settings[key] = value;
      }
    }
  }
  return { colors, settings };
}

/** Every section in an area with its path, and the widgets nested in it (a layout's columns) that have a look of their own. */
function sectionsIn(area: unknown, path: string, into: SectionStyle[], nested = false) {
  const items = (area as { items?: unknown[] } | undefined)?.items ?? [];
  for (const [index, item] of items.entries()) {
    const widget = item as Record<string, unknown>;
    const at = `${path}.items.${index}`;
    const { colors, settings } = lookOf(widget);
    const section = {
      path: at,
      type: String(widget.type),
      background: backgroundOf(widget),
      colors,
      settings,
      customCss: cssOf(widget.sectionCss)
    };
    if (
      !nested ||
      section.background ||
      colors.length ||
      Object.keys(settings).length ||
      section.customCss
    ) {
      into.push(section);
    }
    for (const [key, value] of Object.entries(widget)) {
      if ((value as { metaType?: string } | null)?.metaType === 'area') {
        sectionsIn(value, `${at}.${key}`, into, true);
      }
    }
  }
}

/** A page's draft, as a plain object. */
async function readDraft(site: SiteClient, id: string): Promise<Record<string, unknown>> {
  return { ...(await site.pages().getPage({ id: id.replace(/:published$/, ':draft') })) };
}

/** A page's look: its own style settings, then every section's. */
export async function readPageStyle(site: SiteClient, id: string) {
  const page = await readDraft(site, id);
  const sections: SectionStyle[] = [];
  for (const area of AREAS) {
    sectionsIn(page[area], area, sections);
  }
  const own = Object.fromEntries(
    Object.entries(page).filter(
      ([key]) => !(AREAS as readonly string[]).includes(key) && !key.startsWith('seo')
    )
  );
  return { title: String(page.title), page: lookOf(own), sections };
}

/** Why a change can't be saved, or null when it can. */
function refusal(change: Change): string | null {
  const [area = '', ...rest] = change.path.split('.');
  const field = rest.at(-1) ?? '';
  if (!(AREAS as readonly string[]).includes(area) || rest.length < 3) {
    return `"${change.path}" is not a section's setting. Page settings, Site design and fonts are the owner's: put it in your summary.`;
  }
  if (/css/i.test(field)) {
    return `"${change.path}" is custom CSS. Writing CSS needs the global:css scope, which this key doesn't have: put the CSS change in your summary.`;
  }
  const isColor = COLOR.test(field.replace(/Light$/, ''));
  if (!isColor && !SETTING.test(field) && !BACKGROUND.test(field)) {
    return `"${field}" is not a color, background or style setting.`;
  }
  if (
    isColor &&
    change.after &&
    !/^(#[0-9a-f]{3}|#[0-9a-f]{6}|#[0-9a-f]{8}|--fetchski-[a-z-]+)$/i.test(change.after)
  ) {
    return `"${change.after}" is not a color the site takes: write a palette token such as --fetchski-accent, or a hex color such as #1f2937.`;
  }
  return null;
}

/**
 * Writes the changes to the page's DRAFT. Each change must still match what the agent read
 * (`before`), so an edit someone made meanwhile is never overwritten. Answers in words, for the agent.
 */
export async function saveStyleChanges(
  site: SiteClient,
  id: string,
  changes: readonly Change[]
): Promise<string> {
  const draftId = id.replace(/:published$/, ':draft');
  const page = await readDraft(site, draftId);
  const areas = new Map<string, Record<string, unknown>>();
  for (const change of changes) {
    const refused = refusal(change);
    if (refused) {
      return `Not saved: ${refused}`;
    }
    const [area = '', ...rest] = change.path.split('.');
    if (!areas.has(area)) {
      areas.set(area, structuredClone(page[area]) as Record<string, unknown>);
    }
    let parent = areas.get(area);
    for (const key of rest.slice(0, -1)) {
      parent = parent?.[key] as Record<string, unknown> | undefined;
    }
    const last = rest.at(-1) ?? '';
    if (!parent || String(parent[last] ?? '') !== change.before) {
      return `Not saved: "${change.path}" changed since you read it. Read the page again.`;
    }
    parent[last] = change.after;
  }
  await site.pages().updatePage({
    id: draftId,
    sections: areas.get('sections'),
    aboveSections: areas.get('aboveSections'),
    belowSections: areas.get('belowSections')
  });
  return `Saved ${changes.length} change(s) to the draft of ${draftId}. Nothing is live until a person publishes it.`;
}

/** A private link to a page's draft, for anyone you send it to: no sign-in. */
export async function previewLink(site: SiteClient, id: string): Promise<string> {
  const page = await readDraft(site, id);
  const shared = await site.pages().sharePage({ id, share: true });
  return `${String(page.url)}?${new URLSearchParams({ aposShareKey: shared.aposShareKey ?? '', aposShareId: id })}`;
}
```

**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](https://docs.fetchski-attract.com/find-an-id.md)). `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.

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

```ts src/memory.ts theme={"dark"}
import type { FilesystemBackend } from 'deepagents';
import type { PageRow } from './content.ts';

/** A page, and whether it changed since its memory file was written. */
export interface PageStatus extends PageRow {
  status: 'new' | 'changed' | 'unchanged' | 'next run';
  memoryFile: string; // where the agent keeps its notes on this page
}

/** The most new or changed pages one run reads: a ceiling on time and AI cost. The rest wait for the next run. */
export const MAX_PAGES = Number(process.env.MAX_PAGES || 80);

/** A page's memory file, as the agent sees it: `/memories/pages/<page>.md`. */
export function memoryFileFor(id: string): string {
  return `/memories/pages/${id.split(':')[0]}.md`;
}

/** The same file inside the memories backend, which is mounted at /memories/. */
const inMemories = (file: string) => file.replace(/^\/memories/, '');

/** The page's last-modified time, from the `updated:` line the style-collector copies into its memory file. */
async function updatedInMemory(memories: FilesystemBackend, id: string) {
  const note = await memories.read(inMemories(memoryFileFor(id)), 0, 5);
  return note.error ? null : (/^updated: (.+)$/m.exec(String(note.content))?.[1]?.trim() ?? '');
}

/**
 * Compares every page with its memory file: new (no file yet), changed (the page's last-modified time
 * isn't the one its file records) or unchanged. Past MAX_PAGES, a new or changed page waits for the
 * next run. A changed page's old file is deleted for the style-collector to write again, and the files
 * of pages that no longer exist are deleted.
 */
export async function compareWithMemory(
  memories: FilesystemBackend,
  pages: readonly PageRow[]
): Promise<{ pages: PageStatus[]; removed: string[] }> {
  const statuses: PageStatus[] = [];
  let toRead = 0;
  for (const page of pages) {
    const updated = await updatedInMemory(memories, page.id);
    let status: PageStatus['status'] =
      updated === null ? 'new' : updated === page.updatedAt ? 'unchanged' : 'changed';
    if (status !== 'unchanged' && ++toRead > MAX_PAGES) {
      status = 'next run';
    }
    if (status === 'changed') {
      await memories.delete(inMemories(memoryFileFor(page.id)));
    }
    statuses.push({ ...page, status, memoryFile: memoryFileFor(page.id) });
  }
  const live = new Set(statuses.map((page) => inMemories(page.memoryFile)));
  const removed = ((await memories.ls('/pages/')).files ?? [])
    .map((file) => file.path)
    .filter((path) => !live.has(path));
  for (const path of removed) {
    await memories.delete(path);
  }
  return { pages: statuses, removed };
}
```

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

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

```ts src/tools.ts theme={"dark"}
import { resolve } from 'node:path';
import type { SiteClient } from '@fetchski/attract-cms-sdk';
import { interrupt } from '@langchain/langgraph';
import { FilesystemBackend } from 'deepagents';
import { tool } from 'langchain';
import { z } from 'zod';
import { listPages, readPageStyle, readSiteDesign, saveStyleChanges } from './content.ts';
import { checkContrast, paletteContrast } from './design.ts';
import { compareWithMemory, type PageStatus } from './memory.ts';

/** One run: the agent's long-term memory, the pages it listed, and the drafts it saved (id → title). */
export interface Run {
  memories: FilesystemBackend;
  pages: PageStatus[];
  saved: Map<string, string>;
}

/**
 * A fresh run. Its long-term memory is a folder on disk (MEMORY_FOLDER in .env, else `memories`), the
 * Deep Agents FilesystemBackend the agent sees as /memories/.
 */
export function newRun(memoryFolder = process.env.MEMORY_FOLDER || 'memories'): Run {
  const memories = new FilesystemBackend({ rootDir: resolve(memoryFolder), virtualMode: true });
  return { memories, pages: [], saved: new Map() };
}

/** Our SDK's operations, as the agent's tools. `log` shows the agent's progress. */
export function siteTools(site: SiteClient, run: Run, log: (line: string) => void) {
  // Site design is read once a run, by whichever tool needs it first.
  let design: ReturnType<typeof readSiteDesign> | undefined;
  const siteDesign = () => {
    design ??= readSiteDesign(site);
    return design;
  };

  const listPagesTool = tool(
    async () => {
      const { pages, removed } = await compareWithMemory(run.memories, await listPages(site));
      run.pages = pages;
      const count = (status: string) => pages.filter((page) => page.status === status).length;
      log(
        `Found ${pages.length} pages: ${count('new')} new, ${count('changed')} changed, ${count('unchanged')} unchanged.`
      );
      if (count('next run') > 0) {
        log(`${count('next run')} more wait for the next run (MAX_PAGES).`);
      }
      if (removed.length > 0) {
        log(`Forgot ${removed.length} page(s) that no longer exist.`);
      }
      return JSON.stringify(pages.map(({ updatedAt, ...page }) => page));
    },
    {
      name: 'list_pages',
      description:
        'Lists every page: id, title, status (new, changed or unchanged since the last run, or next run: left for a later run) and its memory file. Call it once.',
      schema: z.object({})
    }
  );

  const readSiteDesignTool = tool(
    async () => {
      const { palette, customized, fonts, customCss } = await siteDesign();
      log('Read Site design.');
      return JSON.stringify({
        palette,
        customized,
        paletteContrast: paletteContrast(palette),
        fonts,
        customCss
      });
    },
    {
      name: 'read_site_design',
      description:
        'Reads Site design: the palette in both themes (each token as a hex color), which palette colors the site changed, ' +
        'the contrast of the palette’s own text pairs, the three fonts, and the site-wide custom CSS.',
      schema: z.object({})
    }
  );

  const readPageStyleTool = tool(
    async (args) => {
      const style = await readPageStyle(site, args.id);
      log(`Read the style of "${style.title}".`);
      // The page's last-modified time, for its memory file: the next run compares it with the page's.
      const updated = run.pages.find((page) => page.id === args.id)?.updatedAt ?? '';
      return JSON.stringify({ updated, ...style });
    },
    {
      name: 'read_page_style',
      description:
        "Reads one page's look: when it last changed (updated), its own style settings, then each section's path, type, background in both themes, " +
        "colors (dark and light; '' means not set, so the palette's), other style settings and custom CSS.",
      schema: z.object({ id: z.string().describe('The id from list_pages') })
    }
  );

  const checkContrastTool = tool(
    async (args) => {
      const { palette } = await siteDesign();
      return JSON.stringify(
        checkContrast(args.text, args.background, args.theme, args.size, palette)
      );
    },
    {
      name: 'check_contrast',
      description:
        'Measures one text color against its background in one theme, by the WCAG 2.2 formula: the ratio, what AA ' +
        'needs (4.5 for normal text, 3 for large text and for icons and borders), and whether it passes. Takes colors ' +
        'as read_page_style gives them: a hex color, a palette token like --fetchski-accent, a background preset, or page.',
      schema: z.object({
        text: z.string().describe('The text (or icon) color'),
        background: z.string().describe('The background it sits on'),
        theme: z.enum(['dark', 'light']),
        size: z
          .enum(['normal', 'large', 'ui'])
          .describe('large: headings and big titles; ui: icons and borders')
      })
    }
  );

  const saveStyleChangesTool = tool(
    async (args) => {
      const answer = await saveStyleChanges(site, args.id, args.changes);
      if (answer.startsWith('Saved')) {
        run.saved.set(args.id, args.title);
      }
      return answer;
    },
    {
      name: 'save_style_changes',
      description:
        "Saves changes to ONE page's section settings as a draft, once the owner approves them: colors, backgrounds " +
        'and style settings, never custom CSS. `path` is the section path from read_page_style, a dot, and the field ' +
        '(sections.items.2.ctaRadius); `before` is the exact value read_page_style gave.',
      schema: z.object({
        id: z.string(),
        title: z.string().describe("The page's title, shown to the owner"),
        changes: z
          .array(z.object({ path: z.string(), before: z.string(), after: z.string() }))
          .min(1)
          .describe('Every change to this page, each at its path')
      })
    }
  );

  const askOwner = tool(
    // Pauses the run until the owner answers; their answer is the tool's result.
    async (args) => String(interrupt({ question: args.question })),
    {
      name: 'ask_owner',
      description: 'Asks the website owner one question, and waits for their answer.',
      schema: z.object({ question: z.string() })
    }
  );

  return {
    listPages: listPagesTool,
    readSiteDesign: readSiteDesignTool,
    readPageStyle: readPageStyleTool,
    checkContrast: checkContrastTool,
    saveStyleChanges: saveStyleChangesTool,
    askOwner
  };
}
```

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

**11. Add src/agent.ts: the deep agent**

```ts src/agent.ts theme={"dark"}
import type { SiteClient } from '@fetchski/attract-cms-sdk';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { MemorySaver } from '@langchain/langgraph';
import { ChatOpenRouter } from '@langchain/openrouter';
import { CompositeBackend, createDeepAgent, StateBackend } from 'deepagents';
import { todoListMiddleware } from 'langchain';
import { type Run, siteTools } from './tools.ts';

const CHECK = `You keep a small business website in one design: the same palette, fonts, section backgrounds
and button styles on every page, with text people can read. Different pages were built at different
times, so some drift: a section with its own one-off color, a stray font, a button shaped unlike the rest.

Your long-term memory is /memories/, kept between runs:
- /memories/site.md: the site's design: its palette and fonts, the patterns most pages share, and the
  owner's choices. It is loaded into your instructions at the start of every run.
- /memories/pages/<page>.md: one file per page, written by the style-collector. list_pages names each
  one, and knows from it whether the page changed since.

Work in this order:
1. Plan your work with write_todos.
2. Call read_site_design once, and list_pages once.
3. Hand every new or changed page to the style-collector subagent with the task tool, about eight at a
   time: give the id, title and memory file of each. Unchanged pages already have a current memory file;
   pages marked "next run" wait for a later run, so leave them out of this one.
4. Write /memories/site.md, or update it with edit_file if it already exists: the palette from
   read_site_design, which colors the site changed, the fonts, and the patterns most page files share:
   section backgrounds, heading and eyebrow colors, button variants and corners, font overrides.
5. Hand the page files to the contrast-checker subagent with the task tool, about ten at a time, each
   batch with its own findings file: /findings/contrast-1.md, /findings/contrast-2.md and so on.
6. Read the findings. Compare every page file with /memories/site.md, and write /findings/design.md: each
   outlier with its page, section path and field: a hex color off the palette where the site uses a
   palette token, a font or font override no other page uses, a button variant or corner unlike the
   rest, custom CSS that sets colors or fonts the palette already has.`;

const FIX = `
7. Where the site has two patterns and you can't tell which one is intended (two button corners, two
   eyebrow colors), call ask_owner once per choice: name each one and the pages that use it. Write the
   answer into /memories/site.md, so no later run asks again.
8. For each page with fixes a section setting can make (a one-off color back to its palette token, a
   button to the site's pattern, a text color that fails contrast), call save_style_changes once with
   every change on that page. The owner approves each page. If the owner sends one back with notes,
   change it to match and call save_style_changes again; if they skip one, don't try again.
   Site design (the palette and fonts) and custom CSS are never yours to change: suggest them instead.
9. Finish with a short summary: the contrast failures, the outliers, the drafts you saved, then
   "Suggested for Site design" (each palette or font change, with the pair it fixes) and "Check by eye"
   (each section with an image or video behind its text, and each pair the check couldn't measure).

Each ask_owner call asks ONE question. Write it in Markdown, with a blank line between paragraphs and
between list items.`;

const REPORT_ONLY = `
7. Nobody is at the keyboard for this run, and you can't change the site. Finish with a report for the
   owner: "Contrast" (each failing pair: page, section, field, theme, ratio and what it needs, the
   palette's own pairs first), "Outliers" (each with what the rest of the site does), "Suggested for
   Site design" (palette and font changes, with the pair each fixes) and "Check by eye" (each section
   with an image or video behind its text, and each pair the check couldn't measure).`;

const STYLE_COLLECTOR = `You record how website pages look. For each page you are given, call read_page_style,
then write its memory file (the path you are given) with write_file, in this shape:

# <title>
id: <id>
updated: <updated, copied exactly>
Page: <its own colors and settings, or none>

## <section path> (<section type>)
- background: dark <value>, light <value>
- <field>: dark <value>, light <value>   (one line per color; write "default" for '')
- settings: <field> <value>, <field> <value> (every setting)
- custom CSS: <the CSS, or none>

Copy every value exactly as read_page_style gives it; don't judge it. Answer with only the files you wrote.`;

const CONTRAST_CHECKER = `You check that the text on a website can be read. Read each page file you are
given with read_file. In each section, call check_contrast for every color that colors text or an icon
(fields named for a heading, title, eyebrow, intro, text, label, link, meta, quote, icon) against the
section's background: the dark value in the dark theme, the light value in the light theme. A solid
button's TextColor goes against its Color (its fill). Size: large for headings and big titles, ui for
icons and borders, normal for the rest. Skip colors that are "default": they follow the palette.

Write the findings file you are told, one line per failing pair:
- <page title> | <section path> <field> | <theme> | <ratio>, needs <needs>
Then "Check by eye:", one line per pair check_contrast couldn't measure, with its why. Answer with only
the file's path.`;

export interface AgentOptions {
  /** Nobody is at the keyboard: the agent only reports, with no tool to ask or to write. */
  reportOnly?: boolean;
}

/** The deep agent: our SDK's operations as its tools, two subagents, long-term memory, and the owner's approval before any draft is written. */
export function buildAgent(
  site: SiteClient,
  model: BaseChatModel,
  run: Run,
  log: (line: string) => void,
  { reportOnly = false }: AgentOptions = {}
) {
  const tools = siteTools(site, run, log);
  return createDeepAgent({
    model,
    systemPrompt: CHECK + (reportOnly ? REPORT_ONLY : FIX),
    tools: reportOnly
      ? [tools.readSiteDesign, tools.listPages]
      : [tools.readSiteDesign, tools.listPages, tools.askOwner, tools.saveStyleChanges],
    middleware: [todoListMiddleware()],
    subagents: [
      {
        name: 'style-collector',
        description: "Reads a batch of pages' styles and writes one memory file per page.",
        systemPrompt: STYLE_COLLECTOR,
        tools: [tools.readPageStyle]
      },
      {
        name: 'contrast-checker',
        description:
          'Checks every text color in a batch of page files against its background, and writes the failures to a findings file.',
        systemPrompt: CONTRAST_CHECKER,
        tools: [tools.checkContrast]
      }
    ],
    // Long-term memory: /memories/ is a folder on disk, kept between runs; every other file lives
    // for one run. site.md is loaded into the agent's instructions at the start of each run.
    backend: new CompositeBackend(new StateBackend(), { '/memories/': run.memories }),
    memory: ['/memories/site.md'],
    // Nothing is written without the owner: approve it, edit it, or reject it.
    interruptOn: { save_style_changes: { allowedDecisions: ['approve', 'edit', 'reject'] } },
    checkpointer: new MemorySaver()
  });
}

/** The OpenRouter model and settings named in .env. ChatOpenRouter reads OPENROUTER_API_KEY itself. */
export function modelFromEnv() {
  return new ChatOpenRouter({
    model: process.env.OPENROUTER_MODEL || '~google/gemini-flash-latest',
    temperature: Number(process.env.OPENROUTER_TEMPERATURE || 0),
    maxTokens: Number(process.env.OPENROUTER_MAX_TOKENS || 16000)
  });
}
```

**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](https://docs.langchain.com/oss/javascript/deepagents/memory) 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](#run-it-every-week)). `modelFromEnv()` builds the OpenRouter
model from `.env`.

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

```ts src/review.ts theme={"dark"}
import { randomUUID } from 'node:crypto';
import { Command } from '@langchain/langgraph';
import type { buildAgent } from './agent.ts';
import type { Change } from './content.ts';
import type { Usage } from './usage.ts';

/** A draft the agent wants to save: the document, and every change to it. */
export interface Draft {
  kind: string;
  title: string;
  changes: Change[];
}

/** Your call on a draft: save it all, keep some changes, send it back with notes, or skip it. */
export type Choice =
  | { type: 'all' }
  | { type: 'some'; keep: number[] }
  | { type: 'revise'; feedback: string }
  | { type: 'skip'; reason: string };

/** The person at the keyboard. src/terminal.ts is the real one; the tests script one. */
export interface Owner {
  /** The agent is working (a spinner, in the terminal). */
  busy?(): void;
  /** Answers the agent's question. */
  answer(question: string): Promise<string>;
  /** Decides on a draft the agent wants to save. */
  decide(draft: Draft): Promise<Choice>;
}

/** Nobody at the keyboard: a report-only run, whose agent has no tool to ask or to save. */
export const nobody: Owner = {
  answer: async () => {
    throw new Error('A report-only run never asks.');
  },
  decide: async () => {
    throw new Error('A report-only run never saves.');
  }
};

interface ActionRequest {
  name: string;
  args: { kind?: string; id?: string; title?: string; changes?: Change[] };
}

type Decision =
  | { type: 'approve' }
  | { type: 'reject'; message: string }
  | { type: 'edit'; editedAction: { name: string; args: ActionRequest['args'] } };

/** The most steps (model calls and tool calls) one run may take: a ceiling on AI cost. */
const MAX_STEPS = Number(process.env.AGENT_MAX_STEPS ?? 400);

/**
 * Runs the agent to the end. Each time it pauses, the owner answers its question, or decides on the
 * drafts it wants to write. Resolves the agent's closing summary.
 */
export async function runWithOwner(
  agent: ReturnType<typeof buildAgent>,
  task: string,
  owner: Owner,
  usage?: Usage
): Promise<string> {
  // `usage` counts every model call's tokens and cost, the subagents' too.
  const config = {
    configurable: { thread_id: randomUUID() },
    recursionLimit: MAX_STEPS,
    callbacks: usage ? [usage] : []
  };
  let input: Parameters<typeof agent.invoke>[0] = { messages: [{ role: 'user', content: task }] };

  for (;;) {
    owner.busy?.();
    const result = await agent.invoke(input, config);
    // Several rewriters can pause at once: each pause gets its own answer, by its id.
    const pending = result.__interrupt__ ?? [];
    if (pending.length === 0) {
      const last = result.messages.at(-1);
      return typeof last?.content === 'string' ? last.content : JSON.stringify(last?.content ?? '');
    }

    const answers: Record<string, unknown> = {};
    for (const { id, value } of pending) {
      const paused = value as { question?: string; actionRequests?: ActionRequest[] };
      if (paused.question) {
        answers[String(id)] = await owner.answer(paused.question);
        continue;
      }
      const decisions: Decision[] = [];
      for (const action of paused.actionRequests ?? []) {
        decisions.push(await review(action, owner));
      }
      answers[String(id)] = { decisions };
    }
    input = new Command({ resume: answers });
  }
}

/** Your choice, in the form the agent's approval step takes: approve, edit (fewer changes) or reject. */
async function review(action: ActionRequest, owner: Owner): Promise<Decision> {
  const changes = action.args.changes ?? [];
  const choice = await owner.decide({
    kind: action.args.kind ?? 'page',
    title: action.args.title ?? '',
    changes
  });

  switch (choice.type) {
    case 'all':
      return { type: 'approve' };
    case 'some': {
      const kept = changes.filter((_, index) => choice.keep.includes(index));
      if (kept.length === 0) {
        return {
          type: 'reject',
          message: 'The owner kept none of these changes. Do not try them again.'
        };
      }
      return {
        type: 'edit',
        editedAction: { name: action.name, args: { ...action.args, changes: kept } }
      };
    }
    case 'revise':
      return {
        type: 'reject',
        message:
          `Not saved yet: the owner wants changes first. Their words: "${choice.feedback}". ` +
          'Revise your changes to match, then call the same tool again with every change.'
      };
    case 'skip':
      return {
        type: 'reject',
        message: `The owner skipped this draft${choice.reason ? `: ${choice.reason}` : ''}. Do not try it again.`
      };
  }
}
```

**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`.

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

```ts src/terminal.ts theme={"dark"}
import { stdout } from 'node:process';
import { stripVTControlCharacters, styleText } from 'node:util';
import * as prompt from '@clack/prompts';
import { diffWordsWithSpace } from 'diff';
import { Marked, type Tokens } from 'marked';
import stringWidth from 'string-width';
import yoctoSpinner from 'yocto-spinner';
import type { Change } from './content.ts';
import type { Choice, Draft, Owner } from './review.ts';
import type { Usage } from './usage.ts';

/** You pressed Ctrl+C or Esc at a prompt: the run stops, and the drafts saved so far stay drafts. */
export class Stopped extends Error {}

/**
 * The person at the keyboard, in the terminal: a spinner while the agent works, with what it is
 * doing and what the run has used so far; its words as formatted text.
 */
export function terminalOwner(usage?: Usage): Owner & {
  step: (line: string) => void;
  report: (markdown: string) => void;
  failed: (error: unknown) => void;
} {
  // yocto-spinner's own dots; each frame redraws in one synchronized write, so it never flickers.
  const spin = yoctoSpinner({ stream: stdout });
  // Like yocto-spinner, only a terminal (not TERM=dumb, outside CI) gets live updates; anywhere
  // else each one would print a new line, so output sent to a file gets the start and the steps.
  const live = Boolean(stdout.isTTY) && process.env.TERM !== 'dumb' && !('CI' in process.env);
  let spinning = false;
  let started = 0;
  let clock: NodeJS.Timeout | undefined;
  let steps: string[] = [];
  let doing = 'The agent is working';

  // What the agent is doing, then the tokens, cost and time so far: one line, never wrapped.
  // On a narrow terminal the tokens and cost go first, then the time, before the task is cut.
  const line = () => {
    const seconds = Math.floor((Date.now() - started) / 1000);
    const time = seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
    const spent = usage && `${tokens(usage)} · $${usage.cost.toFixed(4)}`;
    const width = (stdout.columns || 80) - 3;
    const tail =
      [[spent, time], [time]]
        .map((parts) => parts.filter(Boolean).join(' · '))
        .find((text) => width - stringWidth(text) - 2 >= Math.min(stringWidth(doing), 20)) ?? '';
    const room = tail ? width - stringWidth(tail) - 2 : width;
    return tail ? `${fit(doing, room)}  ${styleText('dim', tail)}` : fit(doing, room);
  };
  const withUsage = (text: string) =>
    usage ? `${text}  ${styleText('dim', `${tokens(usage)} · $${usage.cost.toFixed(4)}`)}` : text;
  const refresh = () => {
    if (spinning && live) {
      spin.text = line();
    }
  };
  if (usage) {
    usage.onCall = refresh;
  }

  // The spinner sits on the run's guide line, like the rest of the terminal.
  const startSpinner = () => {
    started = Date.now();
    spinning = true;
    stdout.write(`${styleText('gray', prompt.S_BAR)}\n`);
    spin.start(line());
    if (live) {
      clock = setInterval(refresh, 1000);
    }
  };
  const stopSpinner = (finish: () => void) => {
    clearInterval(clock);
    spin.stop();
    spinning = false;
    finish();
  };

  // What the agent did since you last heard from it, as one dim line.
  const settle = () => {
    if (spinning) {
      const done = withUsage(styleText('dim', steps.join(' · ') || 'Done'));
      stopSpinner(() => prompt.log.step(done, { spacing: 0 }));
    }
    steps = [];
  };

  return {
    busy() {
      if (!spinning) {
        doing = 'The agent is working';
        startSpinner();
      }
    },
    step(text) {
      doing = text.replace(/\.$/, '');
      steps.push(doing);
      refresh();
    },
    async answer(question) {
      settle();
      say(markdown(question), 'cyan');
      return ask(() =>
        prompt.text({ message: 'Your answer', placeholder: 'Type, then press Enter' })
      );
    },
    async decide(draft) {
      settle();
      say(
        [
          styleText('bold', `Save a draft of the ${draft.kind} "${draft.title}"?`),
          ...draft.changes.map((change, index) => showChange(change, index))
        ].join('\n\n'),
        'cyan'
      );
      return decide(draft);
    },
    /** Shows the run's report. */
    report(text) {
      settle();
      say(markdown(text), 'green');
    },
    /** The run stopped on an error: the spinner says so, and the error follows. */
    failed(error) {
      if (spinning) {
        stopSpinner(() => prompt.log.error('The run stopped', { spacing: 0 }));
      }
      prompt.log.error(error instanceof Error ? error.message : String(error));
    }
  };
}

/** "183,204 tokens": read and written together. */
function tokens(usage: Usage): string {
  return `${new Intl.NumberFormat('en-US').format(usage.inputTokens + usage.outputTokens)} tokens`;
}

/**
 * Text cut to a number of terminal cells, never through a character (an emoji counts as two).
 * yocto-spinner counts a line in UTF-16 code units, which can outnumber its cells (a family emoji
 * is 8), so the cut keeps both within the limit.
 */
function fit(text: string, cells: number): string {
  const size = (part: string) => Math.max(stringWidth(part), part.length);
  if (size(text) <= cells) {
    return text;
  }
  let cut = '';
  for (const { segment } of new Intl.Segmenter().segment(text)) {
    if (size(`${cut}${segment}…`) > cells) {
      break;
    }
    cut += segment;
  }
  return `${cut}…`;
}

/** Prints a block beside the run's guide line, each line wrapped to the terminal's width. */
function say(text: string, color: 'cyan' | 'green') {
  prompt.log.message(wrap(text, (stdout.columns || 80) - 4), { symbol: styleText(color, '◆') });
}

// The terminal's color and style codes (ESC [ … m), as styleText writes them.
const STYLE_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*m`, 'g');

/** Wraps each line at spaces, keeping its indent on the lines it wraps onto. Colors don't count. */
export function wrap(text: string, width: number): string {
  const visible = (line: string) => stripVTControlCharacters(line).length;
  return text
    .split('\n')
    .map((line) => {
      // A wrapped line sits under the text, past any list number or bullet.
      const lead =
        /^( *)((\d+\.|•|before|after ) )?/.exec(stripVTControlCharacters(line))?.[0] ?? '';
      const indent = ' '.repeat(lead.length);
      const words = line.trimStart().split(' ');
      const lines: string[] = [];
      let current = line.slice(0, line.length - line.trimStart().length) + (words.shift() ?? '');
      for (const word of words) {
        if (visible(`${current} ${word}`) > width && current.trim()) {
          // The colors in force so far carry onto the next line: the guide line resets them.
          const colors = (lines.join('') + current).match(STYLE_CODES)?.join('') ?? '';
          lines.push(`${current}\x1b[0m`);
          current = indent + colors + word;
        } else {
          current = `${current} ${word}`;
        }
      }
      lines.push(current);
      return lines.join('\n');
    })
    .join('\n');
}

/** Starts and ends the run's frame in the terminal. */
export const intro = (title: string) => prompt.intro(styleText(['bgCyan', 'black'], ` ${title} `));
export const outro = (line: string) => prompt.outro(line);
export const note = (line: string) => prompt.log.info(line);

async function decide(draft: Draft): Promise<Choice> {
  const many = draft.changes.length > 1;
  const choice = await ask(() =>
    prompt.select({
      message: 'Your call',
      options: [
        { value: 'all', label: many ? `Save all ${draft.changes.length} changes` : 'Save it' },
        ...(many ? [{ value: 'some', label: 'Pick which changes to keep' }] : []),
        { value: 'revise', label: 'Tell the agent what to change', hint: 'it tries again' },
        { value: 'skip', label: 'Skip this one', hint: 'nothing is saved' }
      ]
    })
  );
  if (choice === 'some') {
    const keep = await ask(() =>
      prompt.multiselect({
        message: 'Keep these (Space to toggle, Enter to save)',
        options: draft.changes.map((change, index) => ({
          value: index,
          label: `${index + 1}. ${plain(change.after)}`
        })),
        initialValues: draft.changes.map((_, index) => index),
        required: false
      })
    );
    return { type: 'some', keep };
  }
  if (choice === 'revise') {
    return {
      type: 'revise',
      feedback: await ask(() =>
        prompt.text({
          message: 'What should change?',
          validate: (value) => (value?.trim() ? undefined : 'Say what should change.')
        })
      )
    };
  }
  if (choice === 'skip') {
    return {
      type: 'skip',
      reason: await ask(() =>
        prompt.text({ message: 'Why? (optional, the agent reads it)', defaultValue: '' })
      )
    };
  }
  return { type: 'all' };
}

/** Waits for one prompt; Ctrl+C or Esc stops the run. */
async function ask<T>(show: () => Promise<T | symbol>): Promise<Exclude<T, symbol>> {
  const value = await show();
  if (prompt.isCancel(value)) {
    throw new Stopped('You stopped the run.');
  }
  return value as Exclude<T, symbol>;
}

/**
 * One change, numbered: the text before and after, whole, with the words that change marked (red
 * and struck through before, green after). Still readable with the colors gone, pasted elsewhere.
 */
export function showChange(change: Change, index: number): string {
  const parts = diffWordsWithSpace(change.before, change.after);
  const kept = parts.filter((part) => !part.added && !part.removed);
  const label = `${styleText(['bold', 'cyan'], `${index + 1}.`)} ${styleText('dim', change.path)}`;
  // Mostly rewritten: the old text in red and the new in green, with no marks to read through.
  if (
    kept
      .map((part) => part.value)
      .join('')
      .trim().length <
    change.after.length / 2
  ) {
    return [
      label,
      `   ${styleText('dim', 'before')} ${styleText('red', change.before)}`,
      `   ${styleText('dim', 'after ')} ${styleText('green', change.after)}`
    ].join('\n');
  }
  const side = (drop: 'added' | 'removed', mark: Parameters<typeof styleText>[0]) =>
    parts
      .filter((part) => !part[drop])
      .map((part) =>
        part.added || part.removed ? styleText(mark, part.value) : dimTags(part.value)
      )
      .join('');
  return [
    label,
    `   ${styleText('dim', 'before')} ${side('added', ['red', 'strikethrough'])}`,
    `   ${styleText('dim', 'after ')} ${side('removed', ['green', 'bold'])}`
  ].join('\n');
}

/** Rich text's HTML tags, dimmed, so the words stand out. */
const dimTags = (text: string) => text.replace(/<[^>]+>/g, (tag) => styleText('dim', tag));

/** Text with its HTML tags taken out, on one line, for a short label. */
function plain(text: string, max = 70): string {
  const flat = text
    .replace(/<[^>]+>/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
}

// The agent writes Markdown. This renders it for the terminal, with a blank line after every
// paragraph and every list item, so a long answer reads as separate blocks, not a wall of text.
const terminal = new Marked({
  renderer: {
    heading({ tokens }) {
      return `${styleText(['bold', 'cyan'], this.parser.parseInline(tokens))}\n\n`;
    },
    paragraph({ tokens }) {
      return `${this.parser.parseInline(tokens)}\n\n`;
    },
    list(list: Tokens.List) {
      const start = typeof list.start === 'number' ? list.start : 1;
      return list.items
        .map((item, index) => {
          const bullet = list.ordered ? `${start + index}.` : '•';
          const body = this.parser.parse(item.tokens).trim();
          // Lines under the first are indented to sit under its text.
          const pad = ' '.repeat(bullet.length + 1);
          return `${styleText('cyan', bullet)} ${body.replace(/\n/g, `\n${pad}`)}\n\n`;
        })
        .join('');
    },
    text(token) {
      return 'tokens' in token && token.tokens ? this.parser.parseInline(token.tokens) : token.text;
    },
    strong({ tokens }) {
      return styleText('bold', this.parser.parseInline(tokens));
    },
    em({ tokens }) {
      return styleText('italic', this.parser.parseInline(tokens));
    },
    codespan({ text }) {
      return styleText('cyan', text);
    },
    code({ text }) {
      return `${styleText('cyan', text)}\n\n`;
    },
    blockquote({ tokens }) {
      return `${styleText('dim', `│ ${this.parser.parse(tokens).trim().replace(/\n/g, '\n│ ')}`)}\n\n`;
    },
    link({ href, tokens }) {
      const text = this.parser.parseInline(tokens);
      return text === href
        ? styleText('underline', href)
        : `${text} ${styleText('dim', `(${href})`)}`;
    },
    hr() {
      return `${styleText('dim', '─'.repeat(Math.min(stdout.columns || 60, 60)))}\n\n`;
    },
    br() {
      return '\n';
    },
    html({ text }) {
      return text;
    }
  }
});

/** The agent's Markdown, formatted for the terminal. */
export function markdown(text: string): string {
  return String(terminal.parse(text, { async: false })).trim();
}
```

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

**14. Add src/usage.ts: tokens and cost**

```ts src/usage.ts theme={"dark"}
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
import { AIMessage } from '@langchain/core/messages';
import type { ChatGeneration, LLMResult } from '@langchain/core/outputs';

/**
 * Counts every model call in a run, the subagents' too: tokens read and written, and what
 * OpenRouter charged. Pass it in the run's `callbacks`; LangChain calls handleLLMEnd after each call.
 */
export class Usage extends BaseCallbackHandler {
  name = 'usage';
  calls = 0;
  inputTokens = 0;
  outputTokens = 0;
  /** In US dollars, from OpenRouter's own answer (`usage.cost`), never estimated from a price list. */
  cost = 0;
  /** Calls whose answer carried no cost (a streamed answer, or a provider that leaves it out). */
  uncosted = 0;
  /** Called after each model call, e.g. to refresh a running total on screen. */
  onCall?: () => void;

  override handleLLMEnd(output: LLMResult) {
    this.calls += 1;
    const message = (output.generations[0]?.[0] as ChatGeneration | undefined)?.message;
    const usage = AIMessage.isInstance(message) ? message.usage_metadata : undefined;
    this.inputTokens += usage?.input_tokens ?? 0;
    this.outputTokens += usage?.output_tokens ?? 0;
    const cost = output.llmOutput?.tokenUsage?.cost;
    if (typeof cost === 'number') {
      this.cost += cost;
    } else {
      this.uncosted += 1;
    }
    this.onCall?.();
  }

  /** One line: "42 model calls · 183,204 tokens in · 9,876 out · $0.0412". */
  line(): string {
    const count = new Intl.NumberFormat('en-US');
    const parts = [
      `${count.format(this.calls)} model call${this.calls === 1 ? '' : 's'}`,
      `${count.format(this.inputTokens)} tokens in`,
      `${count.format(this.outputTokens)} out`,
      `$${this.cost.toFixed(4)}`
    ];
    const line = parts.join(' · ');
    return this.uncosted > 0
      ? `${line} (${this.uncosted} call${this.uncosted === 1 ? '' : 's'} without a cost)`
      : line;
  }

  /** The run's usage, as a Markdown section for the report and its email. */
  markdown(): string {
    return `## Usage\n\n${this.line()}. The cost is what OpenRouter charged for this run.`;
  }
}
```

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

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

```ts src/report.ts theme={"dark"}
import type { SiteClient } from '@fetchski/attract-cms-sdk';
import { previewLink } from './content.ts';
import type { Usage } from './usage.ts';

/** A draft the run saved, and the private link that previews it. */
export interface WaitingDraft {
  title: string;
  link: string;
}

/** Every page the run saved a draft of (id → title), each with its preview link. */
export async function draftsWaiting(
  site: SiteClient,
  saved: Map<string, string>
): Promise<WaitingDraft[]> {
  const drafts: WaitingDraft[] = [];
  for (const [id, title] of saved) {
    drafts.push({ title, link: await previewLink(site, id) });
  }
  return drafts;
}

/**
 * The run's report, in Markdown: a heading, the agent's summary, the drafts waiting for you, then
 * what the run used (tokens and cost).
 */
export function reportMarkdown(
  summary: string,
  drafts: readonly WaitingDraft[],
  usage?: Usage,
  date = new Date()
) {
  // The site's host for the heading; a missing or mistyped URL still gets a report that says why.
  const site = URL.parse(process.env.FETCHSKI_ATTRACT_URL ?? '')?.host || 'your site';
  const lines = [
    `# One design on ${site}, ${date.toLocaleDateString('en-CA')}`,
    '',
    summary.trim()
  ];
  if (drafts.length > 0) {
    lines.push('', '## Drafts waiting for you', '');
    lines.push('Publish each one in the CMS when it looks right.', '');
    for (const draft of drafts) {
      lines.push(`- "${draft.title}", preview: ${draft.link}`);
    }
  }
  if (usage) {
    lines.push('', usage.markdown());
  }
  return `${lines.join('\n')}\n`;
}
```

**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](#going-further)).

**16. Add src/main.ts: start it**

```ts src/main.ts theme={"dark"}
import { createSiteClient } from '@fetchski/attract-cms-sdk';
import { buildAgent, modelFromEnv } from './agent.ts';
import { draftsWaiting, reportMarkdown } from './report.ts';
import { runWithOwner } from './review.ts';
import { intro, outro, Stopped, terminalOwner } from './terminal.ts';
import { newRun } from './tools.ts';
import { Usage } from './usage.ts';

// FETCHSKI_ATTRACT_URL, FETCHSKI_ATTRACT_API_KEY and FETCHSKI_ATTRACT_SIGNING_SECRET, from .env
const site = createSiteClient();

const run = newRun();
const usage = new Usage();
const owner = terminalOwner(usage);
const agent = buildAgent(site, modelFromEnv(), run, owner.step);

intro('One design');
let summary: string;
try {
  summary = await runWithOwner(
    agent,
    'Check this whole website against its own design and against WCAG contrast, and fix what we can with me.',
    owner,
    usage
  );
} catch (error) {
  if (!(error instanceof Stopped)) {
    owner.failed(error);
    throw error;
  }
  // Ctrl+C at a prompt: the drafts saved so far stay drafts, and the report lists them.
  summary = 'You stopped the run before it finished.';
}

const report = reportMarkdown(summary, await draftsWaiting(site, run.saved), usage);
owner.report(report);
outro(`Done: ${run.saved.size} draft${run.saved.size === 1 ? '' : 's'} saved · ${usage.line()}`);
```

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

**17. Add test/agent.test.ts: the tests**

```ts test/agent.test.ts theme={"dark"}
import { deepStrictEqual, ok, strictEqual } from 'node:assert';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { factories, fakeSite } from '@fetchski/attract-cms-sdk/testing';
import { AIMessage } from '@langchain/core/messages';
import type { LLMResult } from '@langchain/core/outputs';
import { fakeModel } from 'langchain';
import { buildAgent } from '../src/agent.ts';
import { checkContrast, contrastRatio, resolvePalette } from '../src/design.ts';
import { type Choice, type Draft, nobody, type Owner, runWithOwner } from '../src/review.ts';
import { newRun, siteTools } from '../src/tools.ts';
import { Usage } from '../src/usage.ts';

const HOME = 'page-home:en:draft';
const ABOUT = 'page-about:en:draft';
const MONDAY = '2026-10-26T09:00:00.000Z';
const TUESDAY = '2026-10-27T09:00:00.000Z';
// A fresh memory folder, per test.
const memoryFolder = () => mkdtempSync(join(tmpdir(), 'memories-'));

function area(widget: Record<string, unknown>) {
  return { metaType: 'area', items: [{ _id: 'w1', metaType: 'widget', ...widget }] };
}

// Home follows the palette. About was built later: a gray heading on its own white band, and pill buttons.
const PAGES: Record<string, ReturnType<typeof factories.Page>> = {
  [HOME]: factories.Page({
    id: HOME,
    title: 'Home',
    sections: area({
      type: 'intro',
      introBg: 'raised',
      introEyebrowColor: '--fetchski-mint',
      introCtaRadius: 'slight'
    })
  }),
  [ABOUT]: factories.Page({
    id: ABOUT,
    title: 'About us',
    sections: area({
      type: 'media-text',
      mediaTextBg: 'custom',
      mediaTextBgCustom: '#ffffff',
      mediaTextHeadingColor: '#777777',
      mediaTextCtaRadius: 'pill'
    })
  })
};

function siteWithTwoDesigns(updated: Record<string, string> = { [HOME]: MONDAY, [ABOUT]: MONDAY }) {
  return fakeSite({
    'pages.listPages': factories.ListPagesResponse({
      results: Object.entries(updated).map(([id, updatedAt]) => ({
        id,
        title: String(PAGES[id]?.title),
        type: 'default-page',
        updatedAt: new Date(updatedAt)
      }))
    }),
    'pages.getPage': (args) => PAGES[String(args.id)],
    'globalContent.listGlobalContent': factories.GlobalList({
      results: [
        {
          paletteSecondary: '#2dd4bf',
          fontDisplaySource: 'curated',
          fontDisplayCurated: 'fraunces'
        }
      ],
      pages: 1,
      currentPage: 1
    })
  });
}

const HEADING_FIX = {
  path: 'sections.items.0.mediaTextHeadingColor',
  before: '#777777',
  after: '--fetchski-ink'
};
const RADIUS_FIX = { path: 'sections.items.0.mediaTextCtaRadius', before: 'pill', after: 'slight' };

// The moves a real model would make, scripted: the main agent's, each subagent's, then the main agent's summary.
function scriptedModel() {
  return (
    fakeModel()
      .respondWithTools([
        { name: 'read_site_design', args: {} },
        { name: 'list_pages', args: {} }
      ])
      .respondWithTools([
        {
          name: 'task',
          args: {
            subagent_type: 'style-collector',
            description: `Collect ${HOME} "Home" into /memories/pages/page-home.md and ${ABOUT} "About us" into /memories/pages/page-about.md.`
          }
        }
      ])
      // The style-collector subagent:
      .respondWithTools([
        { name: 'read_page_style', args: { id: HOME } },
        { name: 'read_page_style', args: { id: ABOUT } }
      ])
      .respondWithTools([
        {
          name: 'write_file',
          args: {
            file_path: '/memories/pages/page-home.md',
            content: `# Home\nid: ${HOME}\nupdated: ${MONDAY}\n## sections.items.0 (intro)\n- background: dark raised, light raised`
          }
        },
        {
          name: 'write_file',
          args: {
            file_path: '/memories/pages/page-about.md',
            content: `# About us\nid: ${ABOUT}\nupdated: ${MONDAY}\n## sections.items.0 (media-text)\n- background: dark #ffffff, light #ffffff\n- mediaTextHeadingColor: dark #777777, light #777777`
          }
        }
      ])
      .respond(new AIMessage('/memories/pages/page-home.md, /memories/pages/page-about.md'))
      // The main agent again:
      .respondWithTools([
        {
          name: 'task',
          args: {
            subagent_type: 'contrast-checker',
            description:
              'Check /memories/pages/page-home.md and /memories/pages/page-about.md. Findings file: /findings/contrast-1.md'
          }
        }
      ])
      // The contrast-checker subagent:
      .respondWithTools([
        { name: 'read_file', args: { file_path: '/memories/pages/page-about.md' } }
      ])
      .respondWithTools([
        {
          name: 'check_contrast',
          args: { text: '#777777', background: '#ffffff', theme: 'dark', size: 'normal' }
        }
      ])
      .respondWithTools([
        {
          name: 'write_file',
          args: {
            file_path: '/findings/contrast-1.md',
            content: '- About us | sections.items.0 mediaTextHeadingColor | dark | 4.47, needs 4.5'
          }
        }
      ])
      .respond(new AIMessage('/findings/contrast-1.md'))
      // The main agent again:
      .respondWithTools([
        {
          name: 'write_file',
          args: {
            file_path: '/memories/site.md',
            content: '# Site design\n- Buttons: slight corners'
          }
        }
      ])
      .respondWithTools([
        {
          name: 'ask_owner',
          args: { question: 'Home has slight button corners, About us has pill. Which is yours?' }
        }
      ])
      .respondWithTools([
        {
          name: 'save_style_changes',
          args: { id: ABOUT, title: 'About us', changes: [HEADING_FIX, RADIUS_FIX] }
        }
      ])
      .respond(
        new AIMessage('1 contrast failure and 1 outlier on About us, both fixed in a draft.')
      )
  );
}

// The person at the keyboard, scripted: their answers to questions, then their call on each draft.
function scriptedOwner(answers: string[], choices: Choice[]): Owner & { drafts: Draft[] } {
  const drafts: Draft[] = [];
  return {
    drafts,
    answer: async () => answers.shift() ?? '',
    decide: async (draft) => {
      drafts.push(draft);
      return choices.shift() ?? { type: 'skip', reason: '' };
    }
  };
}

test('a first run collects every page into memory, and saves an approved fix as a draft', async () => {
  const site = siteWithTwoDesigns();
  const folder = memoryFolder();
  const run = newRun(folder);
  const owner = scriptedOwner(['Slight'], [{ type: 'all' }]);

  const summary = await runWithOwner(
    buildAgent(site, scriptedModel(), run, () => {}),
    'Check this whole website.',
    owner
  );
  strictEqual(summary, '1 contrast failure and 1 outlier on About us, both fixed in a draft.');
  site.assertCalled('pages.updatePage', (args) => {
    const section = (args.sections as { items: Record<string, unknown>[] }).items[0];
    return (
      args.id === ABOUT &&
      section?.mediaTextHeadingColor === '--fetchski-ink' &&
      section.mediaTextCtaRadius === 'slight'
    );
  });
  deepStrictEqual([...run.saved], [[ABOUT, 'About us']]);
  deepStrictEqual(
    owner.drafts.map((draft) => draft.title),
    ['About us']
  );
  ok(existsSync(join(folder, 'pages', 'page-home.md')));
  ok(existsSync(join(folder, 'site.md')));

  // The next run finds both pages unchanged, from their memory files alone.
  const next = newRun(folder);
  await siteTools(site, next, () => {}).listPages.invoke({});
  deepStrictEqual(
    next.pages.map((page) => page.status),
    ['unchanged', 'unchanged']
  );
});

test('a fix the owner skips is never written', async () => {
  const site = siteWithTwoDesigns();

  await runWithOwner(
    buildAgent(site, scriptedModel(), newRun(memoryFolder()), () => {}),
    'Check this whole website.',
    scriptedOwner(['Slight'], [{ type: 'skip', reason: 'That gray is on purpose' }])
  );

  site.assertNotCalled('pages.updatePage');
});

test('a report-only second run collects only the changed page, and never asks or writes', async () => {
  // The last run scanned Home, About us and a page since deleted. Since then, only About us changed.
  const folder = memoryFolder();
  mkdirSync(join(folder, 'pages'));
  for (const name of ['page-home', 'page-about', 'page-old']) {
    writeFileSync(join(folder, 'pages', `${name}.md`), `# ${name}\nupdated: ${MONDAY}\n`);
  }
  writeFileSync(
    join(folder, 'site.md'),
    '# Site design\n- Buttons: slight corners, the owner chose'
  );
  const site = siteWithTwoDesigns({ [HOME]: MONDAY, [ABOUT]: TUESDAY });
  const run = newRun(folder);
  const model = fakeModel()
    .respondWithTools([{ name: 'list_pages', args: {} }])
    .respondWithTools([
      {
        name: 'task',
        args: {
          subagent_type: 'style-collector',
          description: `Collect ${ABOUT} "About us" into /memories/pages/page-about.md.`
        }
      }
    ])
    .respondWithTools([{ name: 'read_page_style', args: { id: ABOUT } }])
    .respondWithTools([
      {
        name: 'write_file',
        args: {
          file_path: '/memories/pages/page-about.md',
          content: `# About us, as it is now\nupdated: ${TUESDAY}\n`
        }
      }
    ])
    .respond(new AIMessage('/memories/pages/page-about.md'))
    .respond(new AIMessage('## Contrast\nNo failures.'));

  const summary = await runWithOwner(
    buildAgent(site, model, run, () => {}, { reportOnly: true }),
    'Check this whole website, and report what you find.',
    nobody
  );
  strictEqual(summary, '## Contrast\nNo failures.');
  deepStrictEqual(
    run.pages.map((page) => [page.title, page.status]),
    [
      ['Home', 'unchanged'],
      ['About us', 'changed']
    ]
  );
  site.assertCalledTimes('pages.getPage', 1);
  site.assertCalled('pages.getPage', (args) => args.id === ABOUT);
  site.assertNotCalled('pages.updatePage');
  strictEqual(
    readFileSync(join(folder, 'pages', 'page-about.md'), 'utf8'),
    `# About us, as it is now\nupdated: ${TUESDAY}\n`
  );
  strictEqual(existsSync(join(folder, 'pages', 'page-old.md')), false);
  // site.md, with the owner's choice, was in the agent's instructions from the start.
  const instructions = model.calls[0]?.messages.map((message) => message.text).join('\n') ?? '';
  ok(instructions.includes('Buttons: slight corners, the owner chose'));
});

test('check_contrast measures with the WCAG formula, in code', async () => {
  const palette = resolvePalette({});
  strictEqual(contrastRatio('#000000', '#ffffff'), 21);
  strictEqual(Math.round(contrastRatio('#777777', '#ffffff') * 100) / 100, 4.48);
  deepStrictEqual(checkContrast('#777777', '#ffffff', 'light', 'normal', palette), {
    theme: 'light',
    text: '#777777',
    background: '#ffffff',
    ratio: 4.47,
    needs: 4.5,
    passes: false
  });
  const large = checkContrast('#777777', '#ffffff', 'light', 'large', palette);
  ok('passes' in large && large.passes);

  // Through the tool: palette tokens resolve with the site's own palette, in the theme asked.
  const { checkContrast: tool } = siteTools(siteWithTwoDesigns(), newRun(memoryFolder()), () => {});
  const mint = JSON.parse(
    await tool.invoke({
      text: '--fetchski-mint',
      background: 'page',
      theme: 'dark',
      size: 'normal'
    })
  );
  deepStrictEqual([mint.text, mint.background, mint.passes], ['#2dd4bf', '#09090b', true]);
  const overVideo = JSON.parse(
    await tool.invoke({
      text: '#ffffff',
      background: 'image or video',
      theme: 'dark',
      size: 'large'
    })
  );
  deepStrictEqual(overVideo, {
    theme: 'dark',
    measured: false,
    why: 'background: the background is an image or video'
  });
});

test('usage adds up tokens and the cost OpenRouter reports for each call', () => {
  const usage = new Usage();
  const call = (input: number, output: number, cost?: number) =>
    usage.handleLLMEnd({
      generations: [
        [
          {
            text: '',
            message: new AIMessage({
              content: '',
              usage_metadata: {
                input_tokens: input,
                output_tokens: output,
                total_tokens: input + output
              }
            })
          }
        ]
      ],
      // OpenRouter's own `usage` object, as ChatOpenRouter passes it on.
      llmOutput: { tokenUsage: cost === undefined ? {} : { cost } }
    } as unknown as LLMResult);
  call(1000, 200, 0.0012);
  call(3000, 50, 0.0021);
  call(10, 5);

  strictEqual(
    usage.line(),
    '3 model calls · 4,010 tokens in · 255 out · $0.0033 (1 call without a cost)'
  );
  ok(usage.markdown().startsWith('## Usage\n\n3 model calls'));
});
```

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

**memories**

**pages**

**ckq3v8x5b0004.md**

**site.md**

**.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`:

```bash npm theme={"dark"}
npm run typecheck
npm test
```

```bash pnpm theme={"dark"}
pnpm typecheck
pnpm test
```

```bash bun theme={"dark"}
bun run typecheck
bun run test
```

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

```text Terminal theme={"dark"}
✔ a first run collects every page into memory, and saves an approved fix as a draft (112.719ms)
✔ a fix the owner skips is never written (62.43ms)
✔ a report-only second run collects only the changed page, and never asks or writes (29.061792ms)
✔ check_contrast measures with the WCAG formula, in code (1.420042ms)
✔ usage adds up tokens and the cost OpenRouter reports for each call (3.879708ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0
```

## Run it

```bash npm theme={"dark"}
npm start
```

```bash pnpm theme={"dark"}
pnpm start
```

```bash bun theme={"dark"}
bun run start
```

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

```text Terminal theme={"dark"}
┌   One design
│
◇  Read Site design · Found 14 pages: 14 new, 0 changed, 0 unchanged · Read the style of "Home" · …  187,330 tokens · $0.0391
│
◆  Most pages give their buttons slight corners, but About us uses pill corners. Which is your site's
│  style: slight or pill?
│
◇  Your answer
│  Slight
│
◇  Done  214,906 tokens · $0.0446
│
◆  Save a draft of the page "About us"?
│
│  1. sections.items.2.mediaTextHeadingColor
│     before #777777
│     after  --fetchski-ink
│
│  2. sections.items.2.mediaTextCtaRadius
│     before pill
│     after  slight
│
◇  Your call
│  Save all 2 changes
│
│  …
│
◆  One design on acme.example, 2026-10-28
│
│  One contrast failure: the heading on About us (#777777 on #ffffff, 4.47:1, needs 4.5:1), now
│  --fetchski-ink in a draft. Its buttons had pill corners; they now match the rest of the site.
│
│  Suggested for Site design: none.
│
│  Check by eye: the hero on Home, which has text over a video.
│
│  Drafts waiting for you
│
│  Publish each one in the CMS when it looks right.
│
│  • "About us", preview: https://acme.example/about-us?aposShareKey=k8Jq2w&aposShareId=ckq3v8x5b0004%3Aen%3Adraft
│
│  Usage
│
│  31 model calls · 296,118 tokens in · 7,402 out · $0.0618. The cost is what OpenRouter charged for this run.
│
└  Done: 1 draft saved · 31 model calls · 296,118 tokens in · 7,402 out · $0.0618
```

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

> [accordion] FETCHSKI\_ATTRACT\_URL is not set
>
> ```text Terminal theme={"dark"}
> SiteConfigurationError: FETCHSKI_ATTRACT_URL is not set: set it to the site’s public origin (e.g. https://acme.example), or pass createSiteClient({ baseUrl }).
> ```
>
> 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`.

> [accordion] OpenRouter API key is required
>
> ```text Terminal theme={"dark"}
> OpenRouter API key is required. Get one at https://openrouter.ai/keys and set it via the `apiKey` parameter or the OPENROUTER_API_KEY environment variable.
> ```
>
> Add `OPENROUTER_API_KEY` to `.env`.

> [accordion] User not found.
>
> ```text Terminal theme={"dark"}
> OpenRouterAuthError: User not found.
> ```
>
> OpenRouter doesn't know the key: it was mistyped, or deleted. Copy it again from
> [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys).

> [accordion] 403: the key needs a scope
>
> ```text Terminal theme={"dark"}
> Forbidden: Site "acme" answered GET /api/v1/@apostrophecms/global with 403 forbidden: This API key can’t do this: it needs the `global:read` scope (or a scope above it, such as its type’s `:all`).
> ```
>
> 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`.

> [accordion] 401: Invalid API key.
>
> ```text Terminal theme={"dark"}
> AuthenticationFailed: Site "acme" answered GET /api/v1/@apostrophecms/page with 401 invalid: Invalid API key.
> ```
>
> 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.

> [accordion] 401: This request must be signed
>
> The agent reads fine, but the first draft you approve fails:
>
> ```text Terminal theme={"dark"}
> AuthenticationFailed: Site "acme" answered PATCH /api/v1/@apostrophecms/page/ckq3v8x5b0004:en:draft with 401 unauthorized: This request must be signed: send X-Fetchski-Timestamp and X-Fetchski-Signature, made with the key’s signing secret.
> ```
>
> `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).

> [accordion] Recursion limit reached
>
> ```text Terminal theme={"dark"}
> GraphRecursionError: Recursion limit of 400 reached without hitting a stop condition. You can increase the limit by setting the "recursionLimit" config key.
> ```
>
> 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

**1. Add src/save-report.ts**

```ts src/save-report.ts theme={"dark"}
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';

/** Saves the report as reports/<date>-<time>.md, and answers the file's path. */
export async function saveReport(report: string, date = new Date()): Promise<string> {
  const stamp = `${date.toLocaleDateString('en-CA')}-${date.toTimeString().slice(0, 5).replace(':', '')}`;
  const file = join(process.env.REPORTS_FOLDER || 'reports', `${stamp}.md`);
  await mkdir(dirname(file), { recursive: true });
  await writeFile(file, report);
  return file;
}
```

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

**2. 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:

```ts src/main.ts theme={"dark"}
import { saveReport } from './save-report.ts';
import { intro, note, outro, Stopped, terminalOwner } from './terminal.ts';

// … the rest of main.ts, as it is, down to owner.report(report); …

note(`Saved to ${await saveReport(report)}.`);
```

**3. Keep the reports out of git**

The reports quote your site and hold private preview links. Add the folder to `.gitignore`:

```text .gitignore theme={"dark"}
reports
```

**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`.

> [accordion] Nodemailer: through any mail server (SMTP)
>
> **1. Install the mail packages**
>
> ```bash npm theme={"dark"}
> npm install nodemailer
> npm install --save-dev @types/nodemailer
> ```
>
> ```bash pnpm theme={"dark"}
> pnpm add nodemailer
> pnpm add --save-dev @types/nodemailer
> ```
>
> ```bash bun theme={"dark"}
> bun add nodemailer
> bun add --dev @types/nodemailer
> ```
>
> **Why:** [Nodemailer](https://nodemailer.com) sends email through any mail server, and
> [marked](https://marked.js.org), already in your project for the terminal, turns the Markdown
> report into the email's formatted version.
>
> **2. 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:
>
> ```ini .env theme={"dark"}
> # Email the report: your mail server, who sends it, and who gets it
> SMTP_URL="smtps://reports%40acme.example:app-password@smtp.acme.example:465"
> REPORT_EMAIL_FROM="Website reports <reports@acme.example>"
> REPORT_EMAIL_TO="you@acme.example"
> ```
>
> `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.
>
> **3. Add src/email-report.ts**
>
> ```ts src/email-report.ts theme={"dark"}
> import { marked } from 'marked';
> import { createTransport } from 'nodemailer';
>
> /**
>  * Emails the report to REPORT_EMAIL_TO, through the mail server in SMTP_URL. Its first line (the
>  * report's heading) is the subject. Answers what happened, in words: a run never fails over email.
>  */
> export async function emailReport(report: string): Promise<string> {
>   const { SMTP_URL, REPORT_EMAIL_FROM, REPORT_EMAIL_TO } = process.env;
>   if (!SMTP_URL || !REPORT_EMAIL_FROM || !REPORT_EMAIL_TO) {
>     return 'Not emailed: set SMTP_URL, REPORT_EMAIL_FROM and REPORT_EMAIL_TO in .env.';
>   }
>   try {
>     await createTransport(SMTP_URL).sendMail({
>       from: REPORT_EMAIL_FROM,
>       to: REPORT_EMAIL_TO,
>       subject: report.split('\n')[0]?.replace(/^#\s*/, '') ?? 'Your website report',
>       text: report,
>       html: await marked.parse(report)
>     });
>     return `Emailed to ${REPORT_EMAIL_TO}.`;
>   } catch (error) {
>     return `Not emailed: ${error instanceof Error ? error.message : String(error)}`;
>   }
> }
> ```
>
> **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.

> [accordion] Postmark: through your Postmark account
>
> **1. Install the mail packages**
>
> ```bash npm theme={"dark"}
> npm install postmark
> ```
>
> ```bash pnpm theme={"dark"}
> pnpm add postmark
> ```
>
> ```bash bun theme={"dark"}
> bun add postmark
> ```
>
> **Why:** [Postmark's Node.js library](https://postmarkapp.com/developer/integration/official-libraries#node-js)
> sends email through your Postmark account's API (it brings its own types), and
> [marked](https://marked.js.org), already in your project for the terminal, turns the Markdown
> report into the email's formatted version.
>
> **2. 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:
>
> ```ini .env theme={"dark"}
> # Email the report: your Postmark server token, who sends it, and who gets it
> POSTMARK_SERVER_TOKEN="your-server-api-token"
> REPORT_EMAIL_FROM="Website reports <reports@acme.example>"
> REPORT_EMAIL_TO="you@acme.example"
> ```
>
> 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.
>
> **3. Add src/email-report.ts**
>
> ```ts src/email-report.ts theme={"dark"}
> import { marked } from 'marked';
> import { ServerClient } from 'postmark';
>
> /**
>  * Emails the report to REPORT_EMAIL_TO, through Postmark. Its first line (the report's heading)
>  * is the subject. Answers what happened, in words: a run never fails over email.
>  */
> export async function emailReport(report: string): Promise<string> {
>   const { POSTMARK_SERVER_TOKEN, REPORT_EMAIL_FROM, REPORT_EMAIL_TO } = process.env;
>   if (!POSTMARK_SERVER_TOKEN || !REPORT_EMAIL_FROM || !REPORT_EMAIL_TO) {
>     return 'Not emailed: set POSTMARK_SERVER_TOKEN, REPORT_EMAIL_FROM and REPORT_EMAIL_TO in .env.';
>   }
>   try {
>     await new ServerClient(POSTMARK_SERVER_TOKEN).sendEmail({
>       From: REPORT_EMAIL_FROM,
>       To: REPORT_EMAIL_TO,
>       Subject: report.split('\n')[0]?.replace(/^#\s*/, '') ?? 'Your website report',
>       TextBody: report,
>       HtmlBody: await marked.parse(report)
>     });
>     return `Emailed to ${REPORT_EMAIL_TO}.`;
>   } catch (error) {
>     return `Not emailed: ${error instanceof Error ? error.message : String(error)}`;
>   }
> }
> ```
>
> **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.

**1. 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(…)`:

```ts src/main.ts theme={"dark"}
import { emailReport } from './email-report.ts';

// … the rest of main.ts, as it is, down to the line that saves the report …

note(`Saved to ${await saveReport(report)}.`);
note(await emailReport(report));
```

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

**1. Add src/report-only.ts**

```ts src/report-only.ts theme={"dark"}
import { createSiteClient } from '@fetchski/attract-cms-sdk';
import { buildAgent, modelFromEnv } from './agent.ts';
import { emailReport } from './email-report.ts';
import { reportMarkdown } from './report.ts';
import { nobody, runWithOwner } from './review.ts';
import { saveReport } from './save-report.ts';
import { newRun } from './tools.ts';
import { Usage } from './usage.ts';

// A run with nobody at the keyboard (a scheduled one): it checks every page against the site's design
// and WCAG contrast, and reports what it finds. The agent has no tool to ask you or to save a draft, so
// this run never writes to the site. It does keep its memory up to date.
console.log(`Report-only run, ${new Date().toLocaleString('en-US')}`);

// What the run used (tokens and cost) goes in the report, even when the run stops partway.
const usage = new Usage();
let report: string;
try {
  const site = createSiteClient();
  const run = newRun();
  const agent = buildAgent(site, modelFromEnv(), run, (line) => console.log(line), {
    reportOnly: true
  });

  const summary = await runWithOwner(
    agent,
    'Check this whole website against its own design and against WCAG contrast, and report what you find.',
    nobody,
    usage
  );
  report = reportMarkdown(
    `${summary}\n\nTo fix them with the agent, run \`npm start\` at your keyboard.`,
    [],
    usage
  );
} catch (error) {
  // The run stopped (a wrong key, an empty .env, no credit left): the report says why, by email too.
  const reason = error instanceof Error ? error.message : String(error);
  report = reportMarkdown(
    `The check stopped before it finished, so there is no report this time. It stopped with:\n\n` +
      `\`\`\`text\n${reason}\n\`\`\`\n\n` +
      'Run `npm run report` in the project folder to see it again; "If something goes wrong" on the ' +
      "Recipe's page explains the usual causes.",
    [],
    usage
  ).replace(/^# (.+)$/m, '# $1: the check stopped');
  process.exitCode = 1;
}
console.log(`\n${report}`);
console.log(`Saved to ${await saveReport(report)}.`);
console.log(await emailReport(report));
```

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

**2. Add the report script to package.json**

Add this line to `"scripts"` in `package.json`:

```json package.json theme={"dark"}
"report": "node --env-file=.env src/report-only.ts"
```

Try it once by hand, so you know the email arrives before you schedule it:

```bash npm theme={"dark"}
npm run report
```

```bash pnpm theme={"dark"}
pnpm report
```

```bash bun theme={"dark"}
bun run report
```

**You should see** the progress lines, then the report, then:

```text Terminal theme={"dark"}
Saved to reports/2026-10-28-0900.md.
Emailed to you@acme.example.
```

If the last line starts `Not emailed:`, it says what to fix in `.env`.

**3. 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:

```bash Terminal theme={"dark"}
dirname "$(command -v node)"
```

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

```text crontab theme={"dark"}
PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin
0 8 * * 1 cd /Users/you/one-design && mkdir -p reports && npm run report >> reports/run.log 2>&1
```

`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):

```text Email theme={"dark"}
From: Website reports <reports@acme.example>
To: you@acme.example
Subject: One design on acme.example, 2026-11-02

# One design on acme.example, 2026-11-02

## Contrast
- Pricing, section 3 (call to action), light theme: the button's white text on #34ddb3 is 1.72:1;
  it needs 4.5:1.

## Outliers
- About us, section 2: its background is #ff00aa, a color no other page uses and not one in your
  palette. Every other section uses the palette's surface or raised color.

To fix them with the agent, run `npm start` at your keyboard.

## Usage

22 model calls · 201,477 tokens in · 4,980 out · $0.0419. The cost is what OpenRouter charged for this run.
```

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:

**1. Remove the schedule, if you made one**

Delete its line in `crontab -e`.

**2. Revoke the site key**

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

**3. Delete the OpenRouter key**

At [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys).

**4. 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.

## Related

> [card] [Find where your website contradicts itself](https://docs.fetchski-attract.com/recipes/deep-agents-contradictions.md)
>
> The same tools, checking every fact your site states twice

> [card] [Give your whole site one voice](https://docs.fetchski-attract.com/recipes/deep-agents-one-voice.md)
>
> The same tools, rewriting every document in the voice you approve

> [card] [Testing](https://docs.fetchski-attract.com/typescript/testing.md)
>
> `fakeSite()`, factories and assertions

> [card] [Required scopes](https://docs.fetchski-attract.com/scopes.md)
>
> What each scope opens, per content type

> [card] [Deep Agents: memory](https://docs.langchain.com/oss/javascript/deepagents/memory)
>
> Long-term memory as files, in LangChain's docs

> [card] [Deep Agents: backends](https://docs.langchain.com/oss/javascript/deepagents/backends)
>
> Routing `/memories/` to a folder on disk, in LangChain's docs

> [card] [Deep Agents: human-in-the-loop](https://docs.langchain.com/oss/javascript/deepagents/human-in-the-loop)
>
> Approve, edit and reject, in LangChain's docs


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.