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

# Find where your website contradicts itself, and fix it

> A LangChain deep agent reads every page, post and FAQ, finds the facts your site states two ways, asks you which is true, and drafts every fix 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 { createDeepAgent } from 'deepagents';
import { todoListMiddleware } from 'langchain';
import { type SavedDraft, siteTools } from './tools.ts';

const FIND = `You check a small business website for places where it contradicts itself.

A contradiction is two places on the site stating one fact differently: a price, opening hours, a phone
number or address, a year founded, a team size, a guarantee, a delivery time, a policy, a name.
Different wording of the same fact is not a contradiction.

Work in this order:
1. Plan your work with write_todos.
2. Call list_site_content once.
3. Hand the documents to the fact-collector subagent with the task tool, about eight at a time. Give it
   the kind, id and title of each, and the file to write: /facts/batch-1.md, /facts/batch-2.md and so on.
4. Read the /facts files. Write /contradictions.md: each contradiction, every claim with its kind, id
   and path. If there are none, say so and stop.`;

const FIX = `
5. For each contradiction, call ask_owner once: quote each version and where it appears, and ask which is
   true (the owner may also type the correct fact).
6. For each document that needs a fix, call save_draft_changes once with every change it needs. Change
   only the words that are wrong, and keep the HTML around them. The owner approves each document.
   If the owner sends one back with notes, change it to match and call save_draft_changes again. If
   they skip one, do not try it again.
7. Finish with a short summary: what you found, and which documents now have a draft waiting.

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

const REPORT_ONLY = `
5. Nobody is at the keyboard for this run, and you can't change the site. Finish with a report for the
   owner: each contradiction as a short heading, then every version quoted, with the title of the
   document it appears in.`;

const FACT_COLLECTOR = `You collect facts from website documents. For each document you are given, call
read_document, then write every concrete, checkable claim (prices, hours, numbers, years, names, contact
details, guarantees, policies, delivery times) to the file you are told, one line each:
- <claim in your own words> | <kind> <id> <path> | "<the exact words>"
Skip opinions and general marketing language. Answer with only the file's path when you are done.`;

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, a subagent per batch of documents, and the owner's approval before any draft is written. */
export function buildAgent(
  site: SiteClient,
  model: BaseChatModel,
  saved: Map<string, SavedDraft>,
  log: (line: string) => void,
  { reportOnly = false }: AgentOptions = {}
) {
  const tools = siteTools(site, saved, log);
  return createDeepAgent({
    model,
    systemPrompt: FIND + (reportOnly ? REPORT_ONLY : FIX),
    tools: reportOnly
      ? [tools.listSiteContent, tools.readDocument]
      : [tools.listSiteContent, tools.readDocument, tools.askOwner, tools.saveDraftChanges],
    middleware: [todoListMiddleware()],
    subagents: [
      {
        name: 'fact-collector',
        description: 'Reads a batch of documents and writes their concrete facts to a /facts file.',
        systemPrompt: FACT_COLLECTOR,
        tools: [tools.readDocument]
      }
    ],
    // Nothing is written without the owner: approve it, edit it, or reject it.
    interruptOn: { save_draft_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 type { SiteClient } from '@fetchski/attract-cms-sdk';
import { interrupt } from '@langchain/langgraph';
import { tool } from 'langchain';
import { z } from 'zod';
import { type Kind, listDocuments, readWording, saveDraftChanges } from './content.ts';

const kind = z.enum(['page', 'post', 'faq']).describe('page, post (a blog post) or faq');

/** A document the agent saved a draft of. */
export interface SavedDraft {
  kind: Kind;
  title: string;
}

/**
 * Our SDK's operations, as the agent's tools. `saved` collects every document a draft was written
 * for, by id; `log` shows the agent's progress.
 */
export function siteTools(
  site: SiteClient,
  saved: Map<string, SavedDraft>,
  log: (line: string) => void
) {
  const listSiteContent = tool(
    async () => {
      const documents = await listDocuments(site);
      log(`Found ${documents.length} document${documents.length === 1 ? '' : 's'}.`);
      return JSON.stringify(documents);
    },
    {
      name: 'list_site_content',
      description:
        'Lists every page, blog post and FAQ on the website: kind, id and title. Call it once.',
      schema: z.object({})
    }
  );

  const readDocument = tool(
    async (args) => {
      const document = await readWording(site, args.kind, args.id);
      log(`Read the ${args.kind} "${document.title}".`);
      return JSON.stringify(document.wording);
    },
    {
      name: 'read_document',
      description:
        'Reads one document: every piece of its wording, each with the path that finds it again. Rich text is HTML.',
      schema: z.object({ kind, id: z.string().describe('The id from list_site_content') })
    }
  );

  const saveDraftChangesTool = tool(
    async (args) => {
      const answer = await saveDraftChanges(site, args.kind, args.id, args.changes);
      if (answer.startsWith('Saved')) {
        saved.set(args.id, { kind: args.kind, title: args.title });
      }
      return answer;
    },
    {
      name: 'save_draft_changes',
      description:
        'Saves edits to ONE document as a draft, once the owner approves them. Keep every HTML tag and link ' +
        'exactly; change only words. `before` is the exact text read_document gave for that path.',
      schema: z.object({
        kind,
        id: z.string(),
        title: z.string().describe("The document's title, shown to the owner"),
        changes: z
          .array(z.object({ path: z.string(), before: z.string(), after: z.string() }))
          .min(1)
          .describe('Every edit to this document, 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 { listSiteContent, readDocument, saveDraftChanges: saveDraftChangesTool, askOwner };
}
```

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

/** The three kinds of document the agent reads and edits. */
export type Kind = 'page' | 'post' | 'faq';

export interface DocumentRow {
  kind: Kind;
  id: string; // the draft's id, `…:en:draft`
  title: string;
}

/** One piece of wording in a document: where it sits, and what it says (rich text is HTML). */
export interface Wording {
  path: string;
  text: string;
}

/** One edit: the wording at `path` goes from `before` to `after`. */
export interface Change {
  path: string;
  before: string;
  after: string;
}

/** The most documents one run reads: a ceiling on time and AI cost. */
export const MAX_DOCUMENTS = Number(process.env.MAX_DOCUMENTS ?? 80);

/** The fields that hold a document's wording, per kind. Nothing else is ever written. */
export const EDITABLE: Record<Kind, readonly string[]> = {
  page: ['title', 'seoDescription', 'sections'],
  post: ['title', 'excerpt', 'seoDescription', 'main'],
  faq: ['title', 'answer']
};

// Keys inside a widget that never hold wording: ids, types, styling, links.
const NOT_WORDING = /^(_|metaType$|type$|id$)|css|color|icon|font|url|href|slug|image|video/i;

/** Every page, blog post and FAQ, as drafts: the latest wording, published or not. */
export async function listDocuments(site: SiteClient): Promise<DocumentRow[]> {
  const draft = { aposMode: 'draft' } as const;
  const [pages, posts, faqs] = await Promise.all([
    site.pages().listPagesPick(['title', 'type'], draft),
    site.blogPosts().listBlogsPick(['title'], draft),
    site.faqItems().listFaqItemsPick(['title'], draft)
  ]);
  const rows: DocumentRow[] = [
    ...pages
      .filter((page) => page.type !== '@apostrophecms/archive-page')
      .map((page) => ({ kind: 'page' as const, id: page.id, title: String(page.title) })),
    ...posts.map((post) => ({ kind: 'post' as const, id: post.id, title: String(post.title) })),
    ...faqs.map((faq) => ({ kind: 'faq' as const, id: faq.id, title: String(faq.title) }))
  ];
  return rows.slice(0, MAX_DOCUMENTS);
}

/** A document's draft, as a plain object. */
async function readDraft(
  site: SiteClient,
  kind: Kind,
  id: string
): Promise<Record<string, unknown>> {
  const draftId = id.replace(/:published$/, ':draft');
  switch (kind) {
    case 'page':
      return { ...(await site.pages().getPage({ id: draftId })) };
    case 'post':
      return { ...(await site.blogPosts().getBlog({ id: draftId })) };
    case 'faq':
      return { ...(await site.faqItems().getFaqItem({ id: draftId })) };
  }
}

/** A document's title, and every piece of wording in its draft with the path that finds it again. */
export async function readWording(
  site: SiteClient,
  kind: Kind,
  id: string
): Promise<{ title: string; wording: Wording[] }> {
  const doc = await readDraft(site, kind, id);
  const found: Wording[] = [];
  const visit = (value: unknown, path: string, topLevel: boolean) => {
    if (typeof value === 'string') {
      // A top-level field is wording even as one word; inside a widget, wording has a space in it.
      if (value.trim() && (topLevel || /\s/.test(value.trim())) && !/^https?:\/\//.test(value)) {
        found.push({ path, text: value });
      }
    } else if (Array.isArray(value)) {
      for (const [index, item] of value.entries()) {
        visit(item, `${path}.${index}`, false);
      }
    } else if (value && typeof value === 'object') {
      for (const [key, item] of Object.entries(value)) {
        if (!NOT_WORDING.test(key)) {
          visit(item, `${path}.${key}`, false);
        }
      }
    }
  };
  for (const field of EDITABLE[kind]) {
    visit(doc[field], field, true);
  }
  return { title: String(doc.title), wording: found };
}

/**
 * Writes the changes to the document'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 saveDraftChanges(
  site: SiteClient,
  kind: Kind,
  id: string,
  changes: readonly Change[]
): Promise<string> {
  const draftId = id.replace(/:published$/, ':draft');
  const doc = await readDraft(site, kind, draftId);
  const fields = new Map<string, unknown>();
  for (const change of changes) {
    const [field = '', ...rest] = change.path.split('.');
    if (!EDITABLE[kind].includes(field)) {
      return `Not saved: "${change.path}" is not wording this tool may change (allowed: ${EDITABLE[kind].join(', ')}).`;
    }
    if (!fields.has(field)) {
      fields.set(field, structuredClone(doc[field]));
    }
    if (rest.length === 0) {
      if (fields.get(field) !== change.before) {
        return `Not saved: "${change.path}" changed since you read it. Read the document again.`;
      }
      fields.set(field, change.after);
      continue;
    }
    let parent = fields.get(field) as Record<string, unknown> | undefined;
    for (const key of rest.slice(0, -1)) {
      parent = parent?.[key] as Record<string, unknown> | undefined;
    }
    const last = rest[rest.length - 1] ?? '';
    if (!parent || parent[last] !== change.before) {
      return `Not saved: "${change.path}" changed since you read it. Read the document again.`;
    }
    parent[last] = change.after;
  }

  const text = (name: string) => fields.get(name) as string | undefined;
  const area = (name: string) => fields.get(name) as Record<string, unknown> | undefined;
  switch (kind) {
    case 'page':
      await site.pages().updatePage({
        id: draftId,
        title: text('title'),
        seoDescription: text('seoDescription'),
        sections: area('sections')
      });
      break;
    case 'post':
      await site.blogPosts().updateBlog({
        id: draftId,
        title: text('title'),
        excerpt: text('excerpt'),
        seoDescription: text('seoDescription'),
        main: area('main')
      });
      break;
    case 'faq':
      await site
        .faqItems()
        .updateFaqItem({ id: draftId, title: text('title'), answer: area('answer') });
      break;
  }
  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 or post's draft, for anyone you send it to: no sign-in. FAQs have no page of their own. */
export async function previewLink(
  site: SiteClient,
  kind: Kind,
  id: string
): Promise<string | null> {
  if (kind === 'faq') {
    return null;
  }
  const doc = await readDraft(site, kind, id);
  const shared =
    kind === 'page'
      ? await site.pages().sharePage({ id, share: true })
      : await site.blogPosts().shareBlog({ id, share: true });
  return `${String(doc.url)}?${new URLSearchParams({ aposShareKey: shared.aposShareKey ?? '', aposShareId: id })}`;
}
```

```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 ?? 300);

/**
 * 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 { type Kind, previewLink } from './content.ts';
import type { SavedDraft } from './tools.ts';
import type { Usage } from './usage.ts';

/** A draft the run saved, and the private link that previews it (FAQs have no page of their own). */
export interface WaitingDraft {
  kind: Kind;
  title: string;
  link: string | null;
}

/** Every draft the run saved, each with its preview link. */
export async function draftsWaiting(
  site: SiteClient,
  saved: Map<string, SavedDraft>
): Promise<WaitingDraft[]> {
  const drafts: WaitingDraft[] = [];
  for (const [id, draft] of saved) {
    drafts.push({ ...draft, link: await previewLink(site, draft.kind, 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 = [
    `# Contradictions 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 reads right.', '');
    for (const draft of drafts) {
      const link = draft.link ? `, preview: ${draft.link}` : '';
      lines.push(`- ${draft.kind} "${draft.title}"${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 type { SavedDraft } 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 saved = new Map<string, SavedDraft>();
const usage = new Usage();
const owner = terminalOwner(usage);
const agent = buildAgent(site, modelFromEnv(), saved, owner.step);

intro('Find contradictions');
let summary: string;
try {
  summary = await runWithOwner(
    agent,
    'Check this whole website for places where it contradicts itself, and fix them 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, saved), usage);
owner.report(report);
outro(`Done: ${saved.size} draft${saved.size === 1 ? '' : 's'} saved · ${usage.line()}`);
```

```ts test/agent.test.ts wrap theme={"dark"}
import { deepStrictEqual, ok, strictEqual } from 'node:assert';
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 { reportMarkdown } from '../src/report.ts';
import { type Choice, nobody, type Owner, runWithOwner } from '../src/review.ts';
import type { SavedDraft } from '../src/tools.ts';
import { Usage } from '../src/usage.ts';

const PAGE_ID = 'page-about:en:draft';
const FAQ_ID = 'faq-hours:en:draft';

function richText(html: string) {
  return {
    metaType: 'area',
    items: [{ _id: 'w1', metaType: 'widget', type: '@apostrophecms/rich-text', content: html }]
  };
}

// A site whose About page and FAQ disagree about Saturdays.
function siteThatContradictsItself() {
  return fakeSite({
    'pages.listPages': factories.ListPagesResponse({
      results: [{ id: PAGE_ID, title: 'About us', type: 'default-page' }]
    }),
    'blogPosts.listBlogs': factories.BlogList({ results: [], pages: 1, currentPage: 1 }),
    'faqItems.listFaqItems': factories.FaqItemList({
      results: [{ id: FAQ_ID, title: 'When are you open?' }],
      pages: 1,
      currentPage: 1
    }),
    'pages.getPage': factories.Page({
      id: PAGE_ID,
      title: 'About us',
      sections: richText('<p>We are open Monday to Friday, 9 to 5.</p>')
    }),
    'faqItems.getFaqItem': factories.FaqItem({
      id: FAQ_ID,
      title: 'When are you open?',
      answer: richText('<p>Monday to Saturday, 9 to 5.</p>')
    })
  });
}

// The fix the agent proposes: the FAQ's answer, from Saturday to Friday.
const fix = (after: string) => ({
  name: 'save_draft_changes',
  args: {
    kind: 'faq',
    id: FAQ_ID,
    title: 'When are you open?',
    changes: [
      { path: 'answer.items.0.content', before: '<p>Monday to Saturday, 9 to 5.</p>', after }
    ]
  }
});

// The model's moves, in order: what a real model would decide, scripted. With a `revision`, the
// owner sends the first fix back, and the agent saves that instead.
function scriptedModel(revision?: string) {
  let model = fakeModel()
    .respondWithTools([{ name: 'list_site_content', args: {} }])
    .respondWithTools([
      { name: 'read_document', args: { kind: 'page', id: PAGE_ID } },
      { name: 'read_document', args: { kind: 'faq', id: FAQ_ID } }
    ])
    .respondWithTools([
      {
        name: 'ask_owner',
        args: {
          question:
            'About us says Monday to Friday; the FAQ says Monday to Saturday. Which is true?'
        }
      }
    ])
    .respondWithTools([fix('<p>Monday to Friday, 9 to 5.</p>')]);
  if (revision) {
    model = model.respondWithTools([fix(revision)]);
  }
  return model.respond(
    new AIMessage(
      'Fixed one contradiction: the FAQ now matches the About page. Its draft is waiting.'
    )
  );
}

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

test('asks the owner, then saves the approved fix as a draft', async () => {
  const site = siteThatContradictsItself();
  const saved = new Map<string, SavedDraft>();
  const owner = scriptedOwner(['Monday to Friday'], [{ type: 'all' }]);

  const summary = await runWithOwner(
    buildAgent(site, scriptedModel(), saved, () => {}),
    'Check this whole website for contradictions.',
    owner
  );

  strictEqual(
    summary,
    'Fixed one contradiction: the FAQ now matches the About page. Its draft is waiting.'
  );
  site.assertCalled('faqItems.updateFaqItem', (args) => {
    deepStrictEqual(args.answer, richText('<p>Monday to Friday, 9 to 5.</p>'));
    return args.id === FAQ_ID;
  });
  site.assertNotCalled('pages.updatePage');
  deepStrictEqual([...saved], [[FAQ_ID, { kind: 'faq', title: 'When are you open?' }]]);
  deepStrictEqual(owner.questions, [
    'About us says Monday to Friday; the FAQ says Monday to Saturday. Which is true?'
  ]);
});

test('the owner sends a fix back with notes, and the agent saves its revision', async () => {
  const site = siteThatContradictsItself();
  const owner = scriptedOwner(
    ['Monday to Friday'],
    [{ type: 'revise', feedback: 'Say weekdays' }, { type: 'all' }]
  );

  await runWithOwner(
    buildAgent(site, scriptedModel('<p>Weekdays, 9 to 5.</p>'), new Map(), () => {}),
    'Check this whole website for contradictions.',
    owner
  );

  site.assertCalledTimes('faqItems.updateFaqItem', 1);
  site.assertCalled('faqItems.updateFaqItem', (args) => {
    deepStrictEqual(args.answer, richText('<p>Weekdays, 9 to 5.</p>'));
    return true;
  });
});

test('a skipped fix is never written', async () => {
  const site = siteThatContradictsItself();
  const owner = scriptedOwner(
    ['Monday to Friday'],
    [{ type: 'skip', reason: 'We are changing our hours next month anyway' }]
  );

  await runWithOwner(
    buildAgent(site, scriptedModel(), new Map(), () => {}),
    'Check this whole website for contradictions.',
    owner
  );

  site.assertNotCalled('faqItems.updateFaqItem');
});

test('a report-only run reads and reports, and never asks or writes', async () => {
  const site = siteThatContradictsItself();
  const model = fakeModel()
    .respondWithTools([{ name: 'list_site_content', args: {} }])
    .respondWithTools([
      { name: 'read_document', args: { kind: 'page', id: PAGE_ID } },
      { name: 'read_document', args: { kind: 'faq', id: FAQ_ID } }
    ])
    .respond(
      new AIMessage(
        '## Opening days\n- About us: "Monday to Friday"\n- When are you open?: "Monday to Saturday"'
      )
    );

  const summary = await runWithOwner(
    buildAgent(site, model, new Map(), () => {}, { reportOnly: true }),
    'Check this whole website for contradictions, and report them to me.',
    nobody
  );

  strictEqual(summary.startsWith('## Opening days'), true);
  site.assertNotCalled('faqItems.updateFaqItem');
  site.assertNotCalled('pages.updatePage');
});

test('the report lists each draft with its preview link', () => {
  process.env.FETCHSKI_ATTRACT_URL = 'https://acme.example';
  const report = reportMarkdown(
    'Fixed one contradiction.',
    [
      { kind: 'faq', title: 'When are you open?', link: null },
      { kind: 'page', title: 'Pricing', link: 'https://acme.example/pricing?aposShareKey=k8Jq2w' }
    ],
    undefined,
    new Date(2026, 9, 28)
  );

  strictEqual(
    report,
    [
      '# Contradictions on acme.example, 2026-10-28',
      '',
      'Fixed one contradiction.',
      '',
      '## Drafts waiting for you',
      '',
      'Publish each one in the CMS when it reads right.',
      '',
      '- faq "When are you open?"',
      '- page "Pricing", preview: https://acme.example/pricing?aposShareKey=k8Jq2w',
      ''
    ].join('\n')
  );
});

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": "site-contradictions",
  "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: documents read, and steps taken (each model call or tool call is one)
MAX_DOCUMENTS=80
AGENT_MAX_STEPS=300
```

```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 site was written over years, by different people. The About page says you open on Saturdays; an
FAQ says you don't. One post quotes last year's price, another the new one. Visitors notice, and
nobody has the afternoon it takes to reread the whole site side by side. This agent does that reading,
asks you the one question only you can answer (which version is true), then drafts the fixes.

## What you'll build

A small TypeScript program built on [LangChain Deep Agents](https://docs.langchain.com/oss/javascript/deepagents/overview).
The SDK's calls are the agent's tools: it lists every page, blog post and FAQ, and hands them in
batches to a **fact-collector** subagent, which reads each one and writes its facts to a notes file in
the agent's own virtual filesystem. The agent plans the work with a to-do list, compares the notes,
writes down every contradiction, and asks you which version is true. Then it proposes a fix per
document. Each one waits for you, with every change's text before and after and the changed words
marked: save it, keep only some of its changes, tell the agent what to change (it tries again), or
skip it. A saved fix is a **draft**. Nothing goes live until you publish it in the CMS. As it works,
the terminal shows what the run has used, in tokens and dollars, and the report ends with the
total.

```mermaid theme={"dark"}
flowchart LR
    A[Every page, post and FAQ] --> B[Fact collectors<br/>a batch at a time]
    B --> C[Notes in the<br/>agent's files]
    C --> D[Contradictions]
    D --> E[You say which<br/>is true]
    E --> F[A fix per document]
    F -- you approve --> G[Saved as a draft]
    F -- change this --> F
    F -- you skip --> H[Nothing written]
    G --> I[You publish it<br/>in the CMS]
```

> [!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 run over a 50-document site costs about $0.50 to $2.
>   You run it when you choose, so a month costs what your runs cost.
> * **Time:** about 20 minutes to set up; a run takes 5 to 15 minutes plus your answers.

## Make the API key

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

**3. Pick the Content rewrites pack**

Under **Scopes**, keep **Start from a pack** and pick the **Content rewrites** pack. It ticks
these three scopes, and only these:

| Tick | Scope | Why |
| - | - | - |
| **Pages: write drafts** | `content:pages:write` | Lists and reads the pages' drafts, saves the fixes to them, and makes their preview links. |
| **Blog posts: write drafts** | `content:blog:write` | The same for blog posts. |
| **FAQs: write drafts** | `content:faq-items:write` | The same for FAQs. |

Don't add a **publish** or **delete** scope. A write scope writes drafts and reads them;
without a publish scope the key can't put anything live, so even a confused agent can't change
what visitors see. You publish, in the CMS.

Check **Granted** before you create the key: it should list exactly the 3 scopes above.
Your site's packs can be edited, so if it shows any other scope (a publish or delete one, say),
take it out with **Edit scopes**, or have someone who manages API keys choose **Restore default
packs** in **Scope Packs**.

No **Content rewrites** pack in the list? Someone who manages API keys restores it: ⌘K, **Scope
Packs**, **Restore default packs**. Or choose **Pick scopes one by one** and tick the three
scopes above.

**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 site-contradictions/src site-contradictions/test
cd site-contradictions
```

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

**You should see:** nothing printed; `pwd` ends in `/site-contradictions`.

**2. Add package.json**

```json package.json theme={"dark"}
{
  "name": "site-contradictions",
  "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 `site-contradictions`, with the **Secret token** you copied above.

Your own site serves the `@fetchski` packages, and any API key from it lets you in. In your
project's folder (beside its `package.json`), make a file named `.npmrc` holding these two lines:

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

**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: documents read, and steps taken (each model call or tool call is one)
MAX_DOCUMENTS=80
AGENT_MAX_STEPS=300
```

```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 suits checking facts. |
| `OPENROUTER_MAX_TOKENS` | The most the model writes in one answer. 16000 leaves room for a long fix 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_DOCUMENTS` | The most documents one run reads. |
| `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/content.ts: the SDK calls**

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

/** The three kinds of document the agent reads and edits. */
export type Kind = 'page' | 'post' | 'faq';

export interface DocumentRow {
  kind: Kind;
  id: string; // the draft's id, `…:en:draft`
  title: string;
}

/** One piece of wording in a document: where it sits, and what it says (rich text is HTML). */
export interface Wording {
  path: string;
  text: string;
}

/** One edit: the wording at `path` goes from `before` to `after`. */
export interface Change {
  path: string;
  before: string;
  after: string;
}

/** The most documents one run reads: a ceiling on time and AI cost. */
export const MAX_DOCUMENTS = Number(process.env.MAX_DOCUMENTS ?? 80);

/** The fields that hold a document's wording, per kind. Nothing else is ever written. */
export const EDITABLE: Record<Kind, readonly string[]> = {
  page: ['title', 'seoDescription', 'sections'],
  post: ['title', 'excerpt', 'seoDescription', 'main'],
  faq: ['title', 'answer']
};

// Keys inside a widget that never hold wording: ids, types, styling, links.
const NOT_WORDING = /^(_|metaType$|type$|id$)|css|color|icon|font|url|href|slug|image|video/i;

/** Every page, blog post and FAQ, as drafts: the latest wording, published or not. */
export async function listDocuments(site: SiteClient): Promise<DocumentRow[]> {
  const draft = { aposMode: 'draft' } as const;
  const [pages, posts, faqs] = await Promise.all([
    site.pages().listPagesPick(['title', 'type'], draft),
    site.blogPosts().listBlogsPick(['title'], draft),
    site.faqItems().listFaqItemsPick(['title'], draft)
  ]);
  const rows: DocumentRow[] = [
    ...pages
      .filter((page) => page.type !== '@apostrophecms/archive-page')
      .map((page) => ({ kind: 'page' as const, id: page.id, title: String(page.title) })),
    ...posts.map((post) => ({ kind: 'post' as const, id: post.id, title: String(post.title) })),
    ...faqs.map((faq) => ({ kind: 'faq' as const, id: faq.id, title: String(faq.title) }))
  ];
  return rows.slice(0, MAX_DOCUMENTS);
}

/** A document's draft, as a plain object. */
async function readDraft(
  site: SiteClient,
  kind: Kind,
  id: string
): Promise<Record<string, unknown>> {
  const draftId = id.replace(/:published$/, ':draft');
  switch (kind) {
    case 'page':
      return { ...(await site.pages().getPage({ id: draftId })) };
    case 'post':
      return { ...(await site.blogPosts().getBlog({ id: draftId })) };
    case 'faq':
      return { ...(await site.faqItems().getFaqItem({ id: draftId })) };
  }
}

/** A document's title, and every piece of wording in its draft with the path that finds it again. */
export async function readWording(
  site: SiteClient,
  kind: Kind,
  id: string
): Promise<{ title: string; wording: Wording[] }> {
  const doc = await readDraft(site, kind, id);
  const found: Wording[] = [];
  const visit = (value: unknown, path: string, topLevel: boolean) => {
    if (typeof value === 'string') {
      // A top-level field is wording even as one word; inside a widget, wording has a space in it.
      if (value.trim() && (topLevel || /\s/.test(value.trim())) && !/^https?:\/\//.test(value)) {
        found.push({ path, text: value });
      }
    } else if (Array.isArray(value)) {
      for (const [index, item] of value.entries()) {
        visit(item, `${path}.${index}`, false);
      }
    } else if (value && typeof value === 'object') {
      for (const [key, item] of Object.entries(value)) {
        if (!NOT_WORDING.test(key)) {
          visit(item, `${path}.${key}`, false);
        }
      }
    }
  };
  for (const field of EDITABLE[kind]) {
    visit(doc[field], field, true);
  }
  return { title: String(doc.title), wording: found };
}

/**
 * Writes the changes to the document'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 saveDraftChanges(
  site: SiteClient,
  kind: Kind,
  id: string,
  changes: readonly Change[]
): Promise<string> {
  const draftId = id.replace(/:published$/, ':draft');
  const doc = await readDraft(site, kind, draftId);
  const fields = new Map<string, unknown>();
  for (const change of changes) {
    const [field = '', ...rest] = change.path.split('.');
    if (!EDITABLE[kind].includes(field)) {
      return `Not saved: "${change.path}" is not wording this tool may change (allowed: ${EDITABLE[kind].join(', ')}).`;
    }
    if (!fields.has(field)) {
      fields.set(field, structuredClone(doc[field]));
    }
    if (rest.length === 0) {
      if (fields.get(field) !== change.before) {
        return `Not saved: "${change.path}" changed since you read it. Read the document again.`;
      }
      fields.set(field, change.after);
      continue;
    }
    let parent = fields.get(field) as Record<string, unknown> | undefined;
    for (const key of rest.slice(0, -1)) {
      parent = parent?.[key] as Record<string, unknown> | undefined;
    }
    const last = rest[rest.length - 1] ?? '';
    if (!parent || parent[last] !== change.before) {
      return `Not saved: "${change.path}" changed since you read it. Read the document again.`;
    }
    parent[last] = change.after;
  }

  const text = (name: string) => fields.get(name) as string | undefined;
  const area = (name: string) => fields.get(name) as Record<string, unknown> | undefined;
  switch (kind) {
    case 'page':
      await site.pages().updatePage({
        id: draftId,
        title: text('title'),
        seoDescription: text('seoDescription'),
        sections: area('sections')
      });
      break;
    case 'post':
      await site.blogPosts().updateBlog({
        id: draftId,
        title: text('title'),
        excerpt: text('excerpt'),
        seoDescription: text('seoDescription'),
        main: area('main')
      });
      break;
    case 'faq':
      await site
        .faqItems()
        .updateFaqItem({ id: draftId, title: text('title'), answer: area('answer') });
      break;
  }
  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 or post's draft, for anyone you send it to: no sign-in. FAQs have no page of their own. */
export async function previewLink(
  site: SiteClient,
  kind: Kind,
  id: string
): Promise<string | null> {
  if (kind === 'faq') {
    return null;
  }
  const doc = await readDraft(site, kind, id);
  const shared =
    kind === 'page'
      ? await site.pages().sharePage({ id, share: true })
      : await site.blogPosts().shareBlog({ id, share: true });
  return `${String(doc.url)}?${new URLSearchParams({ aposShareKey: shared.aposShareKey ?? '', aposShareId: id })}`;
}
```

**Why:** everything the agent does to your site, in plain functions over the SDK.
`listDocuments()` finds every page, post and FAQ with the cheap `…Pick()` lists (only ids and
titles, see [Find an id](https://docs.fetchski-attract.com/find-an-id.md)); `readWording()` reads a document's draft and keeps only its
wording, each piece with a path such as `sections.items.2.content`; `saveDraftChanges()` writes
the draft, and refuses if the text changed since the agent read it, so it never overwrites an
editor. Only the fields in `EDITABLE` are ever written.

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

```ts src/tools.ts theme={"dark"}
import type { SiteClient } from '@fetchski/attract-cms-sdk';
import { interrupt } from '@langchain/langgraph';
import { tool } from 'langchain';
import { z } from 'zod';
import { type Kind, listDocuments, readWording, saveDraftChanges } from './content.ts';

const kind = z.enum(['page', 'post', 'faq']).describe('page, post (a blog post) or faq');

/** A document the agent saved a draft of. */
export interface SavedDraft {
  kind: Kind;
  title: string;
}

/**
 * Our SDK's operations, as the agent's tools. `saved` collects every document a draft was written
 * for, by id; `log` shows the agent's progress.
 */
export function siteTools(
  site: SiteClient,
  saved: Map<string, SavedDraft>,
  log: (line: string) => void
) {
  const listSiteContent = tool(
    async () => {
      const documents = await listDocuments(site);
      log(`Found ${documents.length} document${documents.length === 1 ? '' : 's'}.`);
      return JSON.stringify(documents);
    },
    {
      name: 'list_site_content',
      description:
        'Lists every page, blog post and FAQ on the website: kind, id and title. Call it once.',
      schema: z.object({})
    }
  );

  const readDocument = tool(
    async (args) => {
      const document = await readWording(site, args.kind, args.id);
      log(`Read the ${args.kind} "${document.title}".`);
      return JSON.stringify(document.wording);
    },
    {
      name: 'read_document',
      description:
        'Reads one document: every piece of its wording, each with the path that finds it again. Rich text is HTML.',
      schema: z.object({ kind, id: z.string().describe('The id from list_site_content') })
    }
  );

  const saveDraftChangesTool = tool(
    async (args) => {
      const answer = await saveDraftChanges(site, args.kind, args.id, args.changes);
      if (answer.startsWith('Saved')) {
        saved.set(args.id, { kind: args.kind, title: args.title });
      }
      return answer;
    },
    {
      name: 'save_draft_changes',
      description:
        'Saves edits to ONE document as a draft, once the owner approves them. Keep every HTML tag and link ' +
        'exactly; change only words. `before` is the exact text read_document gave for that path.',
      schema: z.object({
        kind,
        id: z.string(),
        title: z.string().describe("The document's title, shown to the owner"),
        changes: z
          .array(z.object({ path: z.string(), before: z.string(), after: z.string() }))
          .min(1)
          .describe('Every edit to this document, 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 { listSiteContent, readDocument, saveDraftChanges: saveDraftChangesTool, askOwner };
}
```

**Why:** each function becomes a LangChain tool, with a zod schema the model fills in.
`ask_owner` calls LangGraph's `interrupt()`: the run pauses, and your typed answer becomes the
tool's result.

**9. 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 { createDeepAgent } from 'deepagents';
import { todoListMiddleware } from 'langchain';
import { type SavedDraft, siteTools } from './tools.ts';

const FIND = `You check a small business website for places where it contradicts itself.

A contradiction is two places on the site stating one fact differently: a price, opening hours, a phone
number or address, a year founded, a team size, a guarantee, a delivery time, a policy, a name.
Different wording of the same fact is not a contradiction.

Work in this order:
1. Plan your work with write_todos.
2. Call list_site_content once.
3. Hand the documents to the fact-collector subagent with the task tool, about eight at a time. Give it
   the kind, id and title of each, and the file to write: /facts/batch-1.md, /facts/batch-2.md and so on.
4. Read the /facts files. Write /contradictions.md: each contradiction, every claim with its kind, id
   and path. If there are none, say so and stop.`;

const FIX = `
5. For each contradiction, call ask_owner once: quote each version and where it appears, and ask which is
   true (the owner may also type the correct fact).
6. For each document that needs a fix, call save_draft_changes once with every change it needs. Change
   only the words that are wrong, and keep the HTML around them. The owner approves each document.
   If the owner sends one back with notes, change it to match and call save_draft_changes again. If
   they skip one, do not try it again.
7. Finish with a short summary: what you found, and which documents now have a draft waiting.

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

const REPORT_ONLY = `
5. Nobody is at the keyboard for this run, and you can't change the site. Finish with a report for the
   owner: each contradiction as a short heading, then every version quoted, with the title of the
   document it appears in.`;

const FACT_COLLECTOR = `You collect facts from website documents. For each document you are given, call
read_document, then write every concrete, checkable claim (prices, hours, numbers, years, names, contact
details, guarantees, policies, delivery times) to the file you are told, one line each:
- <claim in your own words> | <kind> <id> <path> | "<the exact words>"
Skip opinions and general marketing language. Answer with only the file's path when you are done.`;

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, a subagent per batch of documents, and the owner's approval before any draft is written. */
export function buildAgent(
  site: SiteClient,
  model: BaseChatModel,
  saved: Map<string, SavedDraft>,
  log: (line: string) => void,
  { reportOnly = false }: AgentOptions = {}
) {
  const tools = siteTools(site, saved, log);
  return createDeepAgent({
    model,
    systemPrompt: FIND + (reportOnly ? REPORT_ONLY : FIX),
    tools: reportOnly
      ? [tools.listSiteContent, tools.readDocument]
      : [tools.listSiteContent, tools.readDocument, tools.askOwner, tools.saveDraftChanges],
    middleware: [todoListMiddleware()],
    subagents: [
      {
        name: 'fact-collector',
        description: 'Reads a batch of documents and writes their concrete facts to a /facts file.',
        systemPrompt: FACT_COLLECTOR,
        tools: [tools.readDocument]
      }
    ],
    // Nothing is written without the owner: approve it, edit it, or reject it.
    interruptOn: { save_draft_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`,
`ls` …) for its notes, the `task` tool to start a **fact-collector** subagent per batch (each sees
only its own batch, so the main agent's context stays small), and `write_todos` from
`todoListMiddleware()` for its plan. `interruptOn` pauses before every `save_draft_changes`, and
the `MemorySaver` checkpointer keeps the run's state while it waits for you. `reportOnly` builds
the same agent without `ask_owner` and `save_draft_changes`, for a run with nobody at the keyboard
([Going further](#run-it-every-week)). `modelFromEnv()` builds the OpenRouter model from `.env`.

**10. 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 ?? 300);

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

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

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

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

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

/** A draft the run saved, and the private link that previews it (FAQs have no page of their own). */
export interface WaitingDraft {
  kind: Kind;
  title: string;
  link: string | null;
}

/** Every draft the run saved, each with its preview link. */
export async function draftsWaiting(
  site: SiteClient,
  saved: Map<string, SavedDraft>
): Promise<WaitingDraft[]> {
  const drafts: WaitingDraft[] = [];
  for (const [id, draft] of saved) {
    drafts.push({ ...draft, link: await previewLink(site, draft.kind, 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 = [
    `# Contradictions 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 reads right.', '');
    for (const draft of drafts) {
      const link = draft.link ? `, preview: ${draft.link}` : '';
      lines.push(`- ${draft.kind} "${draft.title}"${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 (a page or post; FAQs have no page of their own). The link works for
anyone you send it to, without signing in. Markdown reads well in a terminal, and the same report
can go to a file or an email ([Going further](#going-further)).

**14. 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 type { SavedDraft } 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 saved = new Map<string, SavedDraft>();
const usage = new Usage();
const owner = terminalOwner(usage);
const agent = buildAgent(site, modelFromEnv(), saved, owner.step);

intro('Find contradictions');
let summary: string;
try {
  summary = await runWithOwner(
    agent,
    'Check this whole website for places where it contradicts itself, and fix them 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, saved), usage);
owner.report(report);
outro(`Done: ${saved.size} draft${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.

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

```ts test/agent.test.ts theme={"dark"}
import { deepStrictEqual, ok, strictEqual } from 'node:assert';
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 { reportMarkdown } from '../src/report.ts';
import { type Choice, nobody, type Owner, runWithOwner } from '../src/review.ts';
import type { SavedDraft } from '../src/tools.ts';
import { Usage } from '../src/usage.ts';

const PAGE_ID = 'page-about:en:draft';
const FAQ_ID = 'faq-hours:en:draft';

function richText(html: string) {
  return {
    metaType: 'area',
    items: [{ _id: 'w1', metaType: 'widget', type: '@apostrophecms/rich-text', content: html }]
  };
}

// A site whose About page and FAQ disagree about Saturdays.
function siteThatContradictsItself() {
  return fakeSite({
    'pages.listPages': factories.ListPagesResponse({
      results: [{ id: PAGE_ID, title: 'About us', type: 'default-page' }]
    }),
    'blogPosts.listBlogs': factories.BlogList({ results: [], pages: 1, currentPage: 1 }),
    'faqItems.listFaqItems': factories.FaqItemList({
      results: [{ id: FAQ_ID, title: 'When are you open?' }],
      pages: 1,
      currentPage: 1
    }),
    'pages.getPage': factories.Page({
      id: PAGE_ID,
      title: 'About us',
      sections: richText('<p>We are open Monday to Friday, 9 to 5.</p>')
    }),
    'faqItems.getFaqItem': factories.FaqItem({
      id: FAQ_ID,
      title: 'When are you open?',
      answer: richText('<p>Monday to Saturday, 9 to 5.</p>')
    })
  });
}

// The fix the agent proposes: the FAQ's answer, from Saturday to Friday.
const fix = (after: string) => ({
  name: 'save_draft_changes',
  args: {
    kind: 'faq',
    id: FAQ_ID,
    title: 'When are you open?',
    changes: [
      { path: 'answer.items.0.content', before: '<p>Monday to Saturday, 9 to 5.</p>', after }
    ]
  }
});

// The model's moves, in order: what a real model would decide, scripted. With a `revision`, the
// owner sends the first fix back, and the agent saves that instead.
function scriptedModel(revision?: string) {
  let model = fakeModel()
    .respondWithTools([{ name: 'list_site_content', args: {} }])
    .respondWithTools([
      { name: 'read_document', args: { kind: 'page', id: PAGE_ID } },
      { name: 'read_document', args: { kind: 'faq', id: FAQ_ID } }
    ])
    .respondWithTools([
      {
        name: 'ask_owner',
        args: {
          question:
            'About us says Monday to Friday; the FAQ says Monday to Saturday. Which is true?'
        }
      }
    ])
    .respondWithTools([fix('<p>Monday to Friday, 9 to 5.</p>')]);
  if (revision) {
    model = model.respondWithTools([fix(revision)]);
  }
  return model.respond(
    new AIMessage(
      'Fixed one contradiction: the FAQ now matches the About page. Its draft is waiting.'
    )
  );
}

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

test('asks the owner, then saves the approved fix as a draft', async () => {
  const site = siteThatContradictsItself();
  const saved = new Map<string, SavedDraft>();
  const owner = scriptedOwner(['Monday to Friday'], [{ type: 'all' }]);

  const summary = await runWithOwner(
    buildAgent(site, scriptedModel(), saved, () => {}),
    'Check this whole website for contradictions.',
    owner
  );

  strictEqual(
    summary,
    'Fixed one contradiction: the FAQ now matches the About page. Its draft is waiting.'
  );
  site.assertCalled('faqItems.updateFaqItem', (args) => {
    deepStrictEqual(args.answer, richText('<p>Monday to Friday, 9 to 5.</p>'));
    return args.id === FAQ_ID;
  });
  site.assertNotCalled('pages.updatePage');
  deepStrictEqual([...saved], [[FAQ_ID, { kind: 'faq', title: 'When are you open?' }]]);
  deepStrictEqual(owner.questions, [
    'About us says Monday to Friday; the FAQ says Monday to Saturday. Which is true?'
  ]);
});

test('the owner sends a fix back with notes, and the agent saves its revision', async () => {
  const site = siteThatContradictsItself();
  const owner = scriptedOwner(
    ['Monday to Friday'],
    [{ type: 'revise', feedback: 'Say weekdays' }, { type: 'all' }]
  );

  await runWithOwner(
    buildAgent(site, scriptedModel('<p>Weekdays, 9 to 5.</p>'), new Map(), () => {}),
    'Check this whole website for contradictions.',
    owner
  );

  site.assertCalledTimes('faqItems.updateFaqItem', 1);
  site.assertCalled('faqItems.updateFaqItem', (args) => {
    deepStrictEqual(args.answer, richText('<p>Weekdays, 9 to 5.</p>'));
    return true;
  });
});

test('a skipped fix is never written', async () => {
  const site = siteThatContradictsItself();
  const owner = scriptedOwner(
    ['Monday to Friday'],
    [{ type: 'skip', reason: 'We are changing our hours next month anyway' }]
  );

  await runWithOwner(
    buildAgent(site, scriptedModel(), new Map(), () => {}),
    'Check this whole website for contradictions.',
    owner
  );

  site.assertNotCalled('faqItems.updateFaqItem');
});

test('a report-only run reads and reports, and never asks or writes', async () => {
  const site = siteThatContradictsItself();
  const model = fakeModel()
    .respondWithTools([{ name: 'list_site_content', args: {} }])
    .respondWithTools([
      { name: 'read_document', args: { kind: 'page', id: PAGE_ID } },
      { name: 'read_document', args: { kind: 'faq', id: FAQ_ID } }
    ])
    .respond(
      new AIMessage(
        '## Opening days\n- About us: "Monday to Friday"\n- When are you open?: "Monday to Saturday"'
      )
    );

  const summary = await runWithOwner(
    buildAgent(site, model, new Map(), () => {}, { reportOnly: true }),
    'Check this whole website for contradictions, and report them to me.',
    nobody
  );

  strictEqual(summary.startsWith('## Opening days'), true);
  site.assertNotCalled('faqItems.updateFaqItem');
  site.assertNotCalled('pages.updatePage');
});

test('the report lists each draft with its preview link', () => {
  process.env.FETCHSKI_ATTRACT_URL = 'https://acme.example';
  const report = reportMarkdown(
    'Fixed one contradiction.',
    [
      { kind: 'faq', title: 'When are you open?', link: null },
      { kind: 'page', title: 'Pricing', link: 'https://acme.example/pricing?aposShareKey=k8Jq2w' }
    ],
    undefined,
    new Date(2026, 9, 28)
  );

  strictEqual(
    report,
    [
      '# Contradictions on acme.example, 2026-10-28',
      '',
      'Fixed one contradiction.',
      '',
      '## Drafts waiting for you',
      '',
      'Publish each one in the CMS when it reads right.',
      '',
      '- faq "When are you open?"',
      '- page "Pricing", preview: https://acme.example/pricing?aposShareKey=k8Jq2w',
      ''
    ].join('\n')
  );
});

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 with no site and no model. `fakeSite()` stands in
for your site and answers every SDK call, and LangChain's `fakeModel()` plays the model's moves in
order. Nothing is sent, written or spent.

## Your project

When you're done, `site-contradictions` holds these files (and `node_modules`):

**src**

**agent.ts**

**content.ts**

**main.ts**

**report.ts**

**review\.ts**

**terminal.ts**

**tools.ts**

**usage.ts**

**test**

**agent.test.ts**

**.env**

**.gitignore**

**.npmrc**

**package.json**

**package-lock.json**

**tsconfig.json**

The lockfile is `pnpm-lock.yaml` with pnpm, and `bun.lock` with Bun.

## Test it

Check the types, then run the tests. Both are scripts in `package.json`:

```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"}
✔ asks the owner, then saves the approved fix as a draft (64.814708ms)
✔ the owner sends a fix back with notes, and the agent saves its revision (31.573208ms)
✔ a skipped fix is never written (21.517917ms)
✔ a report-only run reads and reports, and never asks or writes (16.378583ms)
✔ the report lists each draft with its preview link (9.259584ms)
✔ usage adds up tokens and the cost OpenRouter reports for each call (2.4035ms)
ℹ tests 6
ℹ pass 6
ℹ 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 words are colored (red before, green after):

```text Terminal theme={"dark"}
┌   Find contradictions
│
◇  Found 23 documents  1,204 tokens · $0.0003
│
◇  Read the page "Home" · Read the page "About us" · Read the faq "When are you open?"  233,518 tokens · $0.0482
│
◆  Your About us page says "open Monday to Friday, 9 to 5", and the FAQ "When are you open?" says
│  "Monday to Saturday, 9 to 5". Which is true?
│
◇  Your answer
│  Monday to Friday
│
◇  Done  241,077 tokens · $0.0497
│
◆  Save a draft of the faq "When are you open?"?
│
│  1. answer.items.0.content
│     before <p>Monday to Saturday, 9 to 5.</p>
│     after  <p>Monday to Friday, 9 to 5.</p>
│
◇  Your call
│  Save it
│
│  …
│
◆  Contradictions on acme.example, 2026-10-28
│
│  I found 2 contradictions: your opening days and the price of the starter package. Both are fixed in drafts.
│
│  Drafts waiting for you
│
│  Publish each one in the CMS when it reads right.
│
│  • faq "When are you open?"
│
│  • page "Pricing", preview: https://acme.example/pricing?aposShareKey=k8Jq2w&aposShareId=ckq3v8x5b0002%3Aen%3Adraft
│
│  Usage
│
│  38 model calls · 412,906 tokens in · 9,140 out · $0.0861. The cost is what OpenRouter charged for this run.
│
└  Done: 2 drafts saved · 38 model calls · 412,906 tokens in · 9,140 out · $0.0861
```

Between the progress lines the agent may think for a minute: it is reading and writing its notes.
At each draft, pick with the arrow keys and Enter:

* **Save it** (or **Save all N changes**) saves the draft.
* **Pick which changes to keep** shows a checklist: Space unticks a change, Enter saves the rest.
* **Tell the agent what to change** asks for your words; the agent revises the fix and shows it again.
* **Skip this one** writes nothing; the agent reads your reason and doesn't try again.

Ctrl+C at any prompt stops the run. The drafts saved so far stay drafts, and the report lists them.
The `$` figures are what OpenRouter charged, read from its own answers, never estimated.

Then open each preview link, and publish the drafts that read right in the CMS (open the document,
**Publish**). Send a preview link to anyone who should read it first: it works without signing in.

## If something goes wrong

> [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/faq-item with 403 forbidden: This API key can’t do this: it needs the `content:faq-items:write` 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. The same goes for `content:pages:write` and
> `content:blog:write`.

> [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/faq-item/ckq3v8x5b0009: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 300 reached without hitting a stop condition. You can increase the limit by setting the "recursionLimit" config key.
> ```
>
> The run hit its step ceiling. On a large site, lower `MAX_DOCUMENTS` and run it again for the
> rest, or raise `AGENT_MAX_STEPS` (more steps, more cost).

## Going further

Three additions, each optional, each building on the one before: keep every report as a file, have
it emailed to you, and run a check every week with nobody at the keyboard.

### Save each report to a file

**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 lists every contradiction it finds, each version quoted
with the document it's in. Its report is saved in `reports/` and **emailed to you**, so the two
parts above come first. The agent gets no tool to ask or to save, so a report-only run never changes
your site. When the report shows something to fix, run `npm start` at your keyboard.

**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 { Usage } from './usage.ts';

// A run with nobody at the keyboard (a scheduled one): it finds the contradictions and reports them.
// The agent has no tool to ask you or to save a draft, so this run never writes to the site.
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 agent = buildAgent(site, modelFromEnv(), new Map(), (line) => console.log(line), {
    reportOnly: true
  });

  const summary = await runWithOwner(
    agent,
    'Check this whole website for places where it contradicts itself, and report them to me.',
    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 reads every document and finds the
contradictions as usual, and reports them instead of asking you. The report is printed,
saved and emailed. If the run stops (a wrong key, a missing `.env` value, no OpenRouter
credit left), you still get an email, with "the check stopped" in its subject and the reason
in it.

**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/site-contradictions && 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: Contradictions on acme.example, 2026-11-02

# Contradictions on acme.example, 2026-11-02

## Opening days
- About us: "open Monday to Friday, 9 to 5"
- FAQ "When are you open?": "Monday to Saturday, 9 to 5"

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

## Usage

27 model calls · 301,655 tokens in · 6,204 out · $0.0623. 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 **Contradiction finder**, 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 document in the CMS, and choose **Discard Draft** from its menu. The live version is
untouched.

## Related

> [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] [Keep your whole site in one design](https://docs.fetchski-attract.com/recipes/deep-agents-one-design.md)
>
> The same tools, checking every page's colors, fonts and contrast against the rest

> [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: 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.