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

# Give your whole website one voice

> A LangChain deep agent learns how you sound from the pages you pick, writes a voice guide you approve, then rewrites every page, post and FAQ in that voice, each draft waiting for your yes.

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

const REWRITE = `You give a small business website one voice. Over the years different people wrote its pages,
posts and FAQs, so they sound like different businesses. You agree with the owner how the business
sounds, and bring every document into that voice, with the owner approving each one.

The voice guide is /memories/voice-guide.md, your long-term memory: it is kept between runs, and loaded
into your agent memory at the start of each one.

Work in this order:
1. Plan your work with write_todos.
2. Call list_site_content once.
3. If your agent memory holds the voice guide, go to step 4. Otherwise call ask_owner: list the titles,
   and ask which two or three sound most like the business at its best. Read those documents with
   read_document, and write /memories/voice-guide.md: who "we" and "you" are, the tone, sentence length, words and phrases to use, words
   and phrases to avoid, punctuation habits, and three short before/after examples taken from the site.
4. Call ask_owner with the whole guide, and ask the owner to approve it or say what to change. Update
   /memories/voice-guide.md with edit_file and ask again until the owner approves it.
5. For every other document, one at a time, hand it to the rewriter subagent with the task tool: give
   its kind, id and title, and tell it the guide is at /memories/voice-guide.md. Never ask the owner
   about a document's changes yourself: the rewriter's save shows them, and the owner decides there.
6. When a rewriter reports a rule the owner gave, add it to /memories/voice-guide.md with edit_file.
7. Finish with a short summary: how many documents you rewrote, and which ones you left as they were.

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

const CHECK = `You check that a small business website still speaks in one voice: the one in the voice guide,
/memories/voice-guide.md, which the owner approved. It is loaded into your agent memory. Nobody is at the
keyboard for this run: you can't change the site, and you never change the guide.

Work in this order:
1. If your agent memory has no voice guide, answer only "There is no voice guide yet: run the agent at
   your keyboard first, with npm start." and stop.
2. Plan your work with write_todos.
3. Call list_site_content once.
4. Hand the documents to the checker subagent with the task tool, about eight at a time: give it the
   kind, id and title of each.
5. Finish with a report for the owner: a heading per document that drifts from the guide, with what
   drifts and a few of its words quoted. Then how many documents already fit.`;

const REWRITER = `You rewrite one website document in the business's own voice. Read /memories/voice-guide.md with
read_file, then the document with read_document. Rewrite only the wording that doesn't follow the guide.
Keep every fact exactly: names, prices, numbers, dates, addresses, links, and every HTML tag. Keep
headings about as long as they were. Then call save_draft_changes ONCE with every change, and the
document's title. If the owner sends it back with notes, revise your changes to match and call
save_draft_changes again. If the document already follows the guide, change nothing. Answer in one
line: what you changed, or that it already fit, then any rule the owner's notes gave, in their words.`;

const CHECKER = `You check website documents against the business's voice. Read /memories/voice-guide.md with
read_file, then each document you are given with read_document. Answer one line per document:
<kind> "<title>": fits, or <kind> "<title>": drifts, how, with a few of its words quoted.`;

export interface AgentOptions {
  /** Nobody is at the keyboard: the agent only checks and reports, with no tool to ask or to write. */
  reportOnly?: boolean;
  /** The folder on disk that keeps the agent's long-term memory (the voice guide) between runs. */
  memoryFolder?: string;
}

/** The deep agent: our SDK's operations as its tools, a subagent per document, 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, memoryFolder = 'memories' }: AgentOptions = {}
) {
  const tools = siteTools(site, saved, log);
  return createDeepAgent({
    model,
    systemPrompt: reportOnly ? CHECK : REWRITE,
    tools: reportOnly
      ? [tools.listSiteContent, tools.readDocument]
      : [tools.listSiteContent, tools.readDocument, tools.askOwner],
    middleware: [todoListMiddleware()],
    subagents: [
      reportOnly
        ? {
            name: 'checker',
            description:
              'Checks a batch of documents against /memories/voice-guide.md, one line each.',
            systemPrompt: CHECKER,
            tools: [tools.readDocument]
          }
        : {
            name: 'rewriter',
            description:
              'Rewrites one document in the voice of /memories/voice-guide.md and saves it as a draft, once the owner approves.',
            systemPrompt: REWRITER,
            tools: [tools.readDocument, tools.saveDraftChanges]
          }
    ],
    // Long-term memory: /memories/ is a folder on disk, kept between runs; every other file lives
    // for one run. The voice guide is loaded into the agent's instructions at the start of each run.
    backend: new CompositeBackend(new StateBackend(), {
      '/memories/': new FilesystemBackend({ rootDir: resolve(memoryFolder), virtualMode: true })
    }),
    memory: ['/memories/voice-guide.md'],
    // 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.3),
    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 = [`# One voice 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('One voice');
let summary: string;
try {
  summary = await runWithOwner(
    agent,
    'Give this whole website one voice, with me approving the voice guide and every document.',
    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 { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { factories, fakeSite } from '@fetchski/attract-cms-sdk/testing';
import { AIMessage, type BaseMessage } from '@langchain/core/messages';
import type { LLMResult } from '@langchain/core/outputs';
import { fakeModel } from 'langchain';
import { buildAgent } from '../src/agent.ts';
import { type Choice, type Draft, 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-returns:en:draft';
// A fresh memory folder, per test.
const memoryFolder = () => mkdtempSync(join(tmpdir(), 'memories-'));
const GUIDE = '# Our voice\n- Plain, warm, direct. "We" and "you".';

const STIFF = '<p>Customers are advised that returns are processed within 14 days.</p>';
const WARM = '<p>Changed your mind? Send it back within 14 days and we will sort it out.</p>';

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

// A site whose About page sounds like the owner, and whose FAQ sounds like a legal notice.
function siteWithTwoVoices() {
  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: 'Can I return something?' }],
      pages: 1,
      currentPage: 1
    }),
    'pages.getPage': factories.Page({
      id: PAGE_ID,
      title: 'About us',
      sections: richText('<p>We fix things properly, and we tell you straight.</p>')
    }),
    'faqItems.getFaqItem': factories.FaqItem({
      id: FAQ_ID,
      title: 'Can I return something?',
      answer: richText(STIFF)
    })
  });
}

// The moves a real model would make, scripted: the main agent's, then the rewriter subagent's, then the main agent's summary.
function scriptedModel() {
  return (
    fakeModel()
      .respondWithTools([{ name: 'list_site_content', args: {} }])
      .respondWithTools([
        {
          name: 'ask_owner',
          args: { question: 'Which pages sound most like you? About us, Can I return something?' }
        }
      ])
      .respondWithTools([{ name: 'read_document', args: { kind: 'page', id: PAGE_ID } }])
      .respondWithTools([
        {
          name: 'write_file',
          args: {
            file_path: '/memories/voice-guide.md',
            content: GUIDE
          }
        }
      ])
      .respondWithTools([
        {
          name: 'ask_owner',
          args: { question: 'Here is the voice guide: … Approve it, or tell me what to change.' }
        }
      ])
      .respondWithTools([
        {
          name: 'task',
          args: {
            subagent_type: 'rewriter',
            description: `Rewrite the faq ${FAQ_ID} "Can I return something?". The guide is at /memories/voice-guide.md.`
          }
        }
      ])
      // The rewriter subagent:
      .respondWithTools([{ name: 'read_file', args: { file_path: '/memories/voice-guide.md' } }])
      .respondWithTools([{ name: 'read_document', args: { kind: 'faq', id: FAQ_ID } }])
      .respondWithTools([
        {
          name: 'save_draft_changes',
          args: {
            kind: 'faq',
            id: FAQ_ID,
            title: 'Can I return something?',
            changes: [{ path: 'answer.items.0.content', before: STIFF, after: WARM }]
          }
        }
      ])
      .respond(new AIMessage('Rewrote the answer in the guide’s voice.'))
      // The main agent again:
      .respond(new AIMessage('Rewrote 1 document; About us already fit the voice.'))
  );
}

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

test('agrees the voice with the owner, then saves each approved rewrite as a draft', async () => {
  const site = siteWithTwoVoices();
  const saved = new Map<string, SavedDraft>();
  const owner = scriptedOwner(['About us', 'Approved'], [{ type: 'all' }]);
  const folder = memoryFolder();

  const summary = await runWithOwner(
    buildAgent(site, scriptedModel(), saved, () => {}, { memoryFolder: folder }),
    'Give this whole website one voice.',
    owner
  );

  strictEqual(summary, 'Rewrote 1 document; About us already fit the voice.');
  site.assertCalled('faqItems.updateFaqItem', (args) => {
    deepStrictEqual(args.answer, richText(WARM));
    return args.id === FAQ_ID;
  });
  site.assertNotCalled('pages.updatePage');
  deepStrictEqual([...saved], [[FAQ_ID, { kind: 'faq', title: 'Can I return something?' }]]);
  deepStrictEqual(
    owner.drafts.map((draft) => [draft.kind, draft.title, draft.changes.length]),
    [['faq', 'Can I return something?', 1]]
  );
  strictEqual(readFileSync(join(folder, 'voice-guide.md'), 'utf8'), GUIDE);
});

test('a rewrite the owner skips is never written', async () => {
  const site = siteWithTwoVoices();
  const owner = scriptedOwner(
    ['About us', 'Approved'],
    [{ type: 'skip', reason: 'Our returns answer has to stay formal' }]
  );

  await runWithOwner(
    buildAgent(site, scriptedModel(), new Map(), () => {}, { memoryFolder: memoryFolder() }),
    'Give this whole website one voice.',
    owner
  );

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

// The rewriter's moves for a run whose guide is already approved: one save, then whatever follows.
function rewriterModel(...after: Array<AIMessage | ((messages: BaseMessage[]) => AIMessage)>) {
  let model = fakeModel()
    .respondWithTools([{ name: 'list_site_content', args: {} }])
    .respondWithTools([
      {
        name: 'task',
        args: { subagent_type: 'rewriter', description: `Rewrite the faq ${FAQ_ID}.` }
      }
    ])
    .respondWithTools([{ name: 'read_document', args: { kind: 'faq', id: FAQ_ID } }])
    .respondWithTools([
      {
        name: 'save_draft_changes',
        args: {
          kind: 'faq',
          id: FAQ_ID,
          title: 'Can I return something?',
          changes: [
            { path: 'title', before: 'Can I return something?', after: 'Changed your mind?' },
            { path: 'answer.items.0.content', before: STIFF, after: WARM }
          ]
        }
      }
    ]);
  for (const step of after) {
    model = model.respond(step);
  }
  return model;
}

function withGuide() {
  const folder = memoryFolder();
  writeFileSync(join(folder, 'voice-guide.md'), GUIDE);
  return folder;
}

test('only the changes the owner keeps are written', async () => {
  const site = siteWithTwoVoices();
  const model = rewriterModel(
    new AIMessage('Rewrote the answer.'),
    new AIMessage('Rewrote 1 document.')
  );

  await runWithOwner(
    buildAgent(site, model, new Map(), () => {}, { memoryFolder: withGuide() }),
    'Give this whole website one voice.',
    scriptedOwner([], [{ type: 'some', keep: [1] }])
  );

  site.assertCalled('faqItems.updateFaqItem', (args) => {
    deepStrictEqual(args.answer, richText(WARM));
    strictEqual(args.title, undefined);
    return true;
  });
});

test('the owner sends a draft back with notes, and the agent revises it before it is saved', async () => {
  const site = siteWithTwoVoices();
  const SHORTER = '<p>Changed your mind? Send it back within 14 days.</p>';
  let notes = '';
  const model = rewriterModel(
    // The rewriter reads the owner's notes, then saves its revision.
    (messages) => {
      notes = String(messages.at(-1)?.content ?? '');
      return new AIMessage({
        content: '',
        tool_calls: [
          {
            id: 'revised',
            name: 'save_draft_changes',
            args: {
              kind: 'faq',
              id: FAQ_ID,
              title: 'Can I return something?',
              changes: [{ path: 'answer.items.0.content', before: STIFF, after: SHORTER }]
            }
          }
        ]
      });
    },
    new AIMessage('Rewrote the answer, shorter, as the owner asked. Rule: keep answers short.'),
    new AIMessage('Rewrote 1 document.')
  );
  const owner = scriptedOwner(
    [],
    [{ type: 'revise', feedback: 'Shorter, and keep the title' }, { type: 'all' }]
  );

  await runWithOwner(
    buildAgent(site, model, new Map(), () => {}, { memoryFolder: withGuide() }),
    'Give this whole website one voice.',
    owner
  );

  ok(notes.includes('Shorter, and keep the title'));
  strictEqual(owner.drafts.length, 2);
  site.assertCalled('faqItems.updateFaqItem', (args) => {
    deepStrictEqual(args.answer, richText(SHORTER));
    return true;
  });
});

test('two rewriters that pause at once each get their own decision', async () => {
  const OTHER_ID = 'faq-shipping:en:draft';
  const site = siteWithTwoVoices();
  // One rewriter step, for whichever rewriter asks: read its FAQ, save it, then answer.
  const rewriter = (messages: BaseMessage[]) => {
    const id = String(messages.find((message) => message.type === 'human')?.content).includes(
      OTHER_ID
    )
      ? OTHER_ID
      : FAQ_ID;
    const done = messages.filter((message) => message.type === 'tool').length;
    if (done === 0) {
      return new AIMessage({
        content: '',
        tool_calls: [{ id: `read-${id}`, name: 'read_document', args: { kind: 'faq', id } }]
      });
    }
    if (done === 1) {
      return new AIMessage({
        content: '',
        tool_calls: [
          {
            id: `save-${id}`,
            name: 'save_draft_changes',
            args: {
              kind: 'faq',
              id,
              title: id,
              changes: [{ path: 'answer.items.0.content', before: STIFF, after: WARM }]
            }
          }
        ]
      });
    }
    return new AIMessage(`Done with ${id}.`);
  };
  const model = fakeModel()
    .respondWithTools([{ name: 'list_site_content', args: {} }])
    .respondWithTools(
      [FAQ_ID, OTHER_ID].map((id) => ({
        name: 'task',
        args: { subagent_type: 'rewriter', description: `Rewrite the faq ${id}.` }
      }))
    )
    .respond(rewriter)
    .respond(rewriter)
    .respond(rewriter)
    .respond(rewriter)
    .respond(rewriter)
    .respond(rewriter)
    .respond(new AIMessage('Rewrote 1 document.'));
  // The owner saves the first draft they see and skips the second.
  const owner = scriptedOwner([], [{ type: 'all' }, { type: 'skip', reason: '' }]);

  await runWithOwner(
    buildAgent(site, model, new Map(), () => {}, { memoryFolder: withGuide() }),
    'Give this whole website one voice.',
    owner
  );

  strictEqual(owner.drafts.length, 2);
  const [first, second] = owner.drafts.map((draft) => draft.title);
  site.assertCalled('faqItems.updateFaqItem', (args) => args.id === first);
  ok(second && second !== first);
  site.assertCalledTimes('faqItems.updateFaqItem', 1);
});

test('a report-only run with no voice guide stops, and never asks or writes', async () => {
  const site = siteWithTwoVoices();
  const model = fakeModel().respond(
    new AIMessage(
      'There is no voice guide yet: run the agent at your keyboard first, with npm start.'
    )
  );

  const summary = await runWithOwner(
    buildAgent(site, model, new Map(), () => {}, {
      reportOnly: true,
      memoryFolder: memoryFolder()
    }),
    'Check every document against our voice guide.',
    nobody
  );

  strictEqual(summary.startsWith('There is no voice guide yet'), true);
  ok(model.calls[0]?.messages[0]?.text.includes('(No memory loaded)'));
  site.assertNotCalled('faqItems.updateFaqItem');
});

test('a report-only run checks the documents against the guide in its memory', async () => {
  const site = siteWithTwoVoices();
  const folder = memoryFolder();
  writeFileSync(join(folder, 'voice-guide.md'), GUIDE);
  const model = fakeModel()
    .respondWithTools([{ name: 'list_site_content', args: {} }])
    .respondWithTools([
      {
        name: 'task',
        args: {
          subagent_type: 'checker',
          description: `Check page ${PAGE_ID} "About us" and faq ${FAQ_ID} "Can I return something?".`
        }
      }
    ])
    // The checker subagent:
    .respondWithTools([{ name: 'read_file', args: { file_path: '/memories/voice-guide.md' } }])
    .respondWithTools([
      { name: 'read_document', args: { kind: 'page', id: PAGE_ID } },
      { name: 'read_document', args: { kind: 'faq', id: FAQ_ID } }
    ])
    .respond(
      new AIMessage(
        'page "About us": fits\nfaq "Can I return something?": drifts, stiff: "Customers are advised"'
      )
    )
    // The main agent again:
    .respond(new AIMessage('## Can I return something?\nStiff: "Customers are advised". 1 fits.'));

  const summary = await runWithOwner(
    buildAgent(site, model, new Map(), () => {}, { reportOnly: true, memoryFolder: folder }),
    'Check every document against our voice guide.',
    nobody
  );

  strictEqual(summary, '## Can I return something?\nStiff: "Customers are advised". 1 fits.');
  // The guide was in the agent's instructions from the start: its long-term memory.
  ok(model.calls[0]?.messages[0]?.text.includes(GUIDE));
  site.assertCalled('faqItems.getFaqItem', (args) => args.id === FAQ_ID);
  site.assertNotCalled('faqItems.updateFaqItem');
});

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'));
});

test("a run's usage counts the subagents' model calls too", async () => {
  const site = siteWithTwoVoices();
  const usage = new Usage();
  const used = (message: AIMessage) => {
    message.usage_metadata = { input_tokens: 100, output_tokens: 10, total_tokens: 110 };
    return message;
  };
  const model = fakeModel()
    .respond(
      used(
        new AIMessage({
          content: '',
          tool_calls: [
            {
              id: 't',
              name: 'task',
              args: { subagent_type: 'rewriter', description: `Rewrite the faq ${FAQ_ID}.` }
            }
          ]
        })
      )
    )
    // The rewriter subagent:
    .respond(used(new AIMessage('It already fits.')))
    // The main agent again:
    .respond(used(new AIMessage('Rewrote 0 documents.')));

  await runWithOwner(
    buildAgent(site, model, new Map(), () => {}, { memoryFolder: withGuide() }),
    'Give this whole website one voice.',
    scriptedOwner([], []),
    usage
  );

  strictEqual(usage.calls, 3);
  strictEqual(usage.inputTokens, 300);
  strictEqual(usage.outputTokens, 30);
});
```

```json package.json theme={"dark"}
{
  "name": "one-voice",
  "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.3
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=400
```

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

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

## The situation

Five people wrote your site over six years. The home page is warm, the services page reads like a
contract, the FAQs sound like a call center and the blog changes voice with every author. Visitors
feel it even when they can't name it. Rewriting forty documents by hand, consistently, is a week
nobody has. This agent does it in an afternoon, in the voice of your best pages, and you approve
every word before it is saved.

## 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. The agent plans the work with a to-do list, lists every page,
blog post and FAQ, and asks you which two or three sound most like you at your best. It reads those,
writes a **voice guide**, and shows it to you until you approve it. The guide is the agent's
long-term [memory](https://docs.langchain.com/oss/javascript/deepagents/memory), a real file in your
project, `memories/voice-guide.md`: every run starts with it in the agent's instructions, and a weekly
check can hold the whole site to it. Then it hands every other document, one at a time, to a **rewriter** subagent,
which reads the guide and the document and proposes its rewrite. Each rewrite 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 rewrite is a **draft**;
facts, prices, names and links stay exactly as they were. 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[You pick the pages<br/>that sound like you]
    B --> C[A voice guide<br/>memories/voice-guide.md]
    C --> D{You approve it?}
    D -- change this --> C
    D -- yes --> E[A rewriter per document]
    E --> F{You approve<br/>the rewrite?}
    F -- change this --> E
    F -- yes --> G[Saved as a draft]
    F -- 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 $1 to $4:
>   rewriting writes far more than checking. You run it once, then again only when the site has
>   drifted; a weekly check that only reports costs far less ([Going further](#run-it-every-week)).
> * **Time:** about 20 minutes to set up; a run takes 20 to 40 minutes, most of it your reading.

## Make the API key

**1. Open API Keys**

Sign in to your site's CMS, press **⌘K** (**Ctrl+K** on Windows),
type `API Keys` and press **Enter**. Click **Create API Key**.

**You should see:** the Create API Key form: **Label**, **Scopes**, **Expires**, **Request signing**.

**2. Name it**

**Label:** `One voice`.

**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 rewrites 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 one-voice/src one-voice/test
cd one-voice
```

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

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

**2. Add package.json**

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

**Why:** the packages and their versions. `deepagents` is LangChain's Deep Agents,
`@langchain/openrouter` its OpenRouter chat model, `@fetchski/attract-cms-sdk` the SDK.
[`@clack/prompts`](https://bomb.sh/docs/clack/packages/prompts), [`diff`](https://github.com/kpdecker/jsdiff),
[`marked`](https://marked.js.org), [`string-width`](https://github.com/sindresorhus/string-width) and
[`yocto-spinner`](https://github.com/sindresorhus/yocto-spinner) draw the conversation in your
terminal. Node runs
the TypeScript files directly (`node src/main.ts`), so there is no build step; TypeScript is only
for checking.

**3. Point the @fetchski scope at your site**

Do this in `one-voice`, with the **Secret token** you copied above.

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

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

The first line says `@fetchski` packages come from your site; the second is the key to sign in with.
Put your site's address in both lines (set your site in the bar under this page's title and the
example shows it), and paste your whole key in place of `fsk_acme_…`. Write the key itself, never a
variable or a value from `.env`: npm and pnpm don't read `.env`, and pnpm refuses a variable here.

> [!NOTE]
>
> **Any API key from your site works**, the same one your app uses included: its scopes and signing
> mode don't matter for installing, only that it's switched on and not past its expiry date. No key
> yet? Make one in the CMS under **API Keys** ([Authentication](https://docs.fetchski-attract.com/authentication.md)).

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

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

> [!WARNING]
>
> **`.npmrc` now holds a key to your site: never commit it.** If git already tracks a `.npmrc`, stop
> tracking it with `git rm --cached .npmrc`. Keep these lines in this project's `.npmrc` only, never
> in the `.npmrc` in your home folder. In CI, write the same file from a secret before installing.

**Why:** your site serves the Fetchski SDK, so the install asks it for every `@fetchski/…`
package, with your key.

**You should see:** `.npmrc` beside `package.json`, and `.npmrc` in `.gitignore`.

**4. Install**

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

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

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

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

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

**You should see:** `installed`. A `401 Unauthorized` during the install means your site refused
the key in `.npmrc` (mistyped, switched off or past its **Expires** date), or `.npmrc` isn't in
`one-voice`.

**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.3
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=400
```

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

**Why:** the SDK reads the three `FETCHSKI_ATTRACT_*` values on its own, and the OpenRouter model
reads `OPENROUTER_API_KEY`. `.gitignore` keeps `.env` and `.npmrc` (your keys) out of git.

The other values are yours to change, and every one has a default in the code:

| Setting | What it does |
| - | - |
| `OPENROUTER_MODEL` | The model the agent thinks with. Any model from [openrouter.ai/models](https://openrouter.ai/models) that supports tools works. `~google/gemini-flash-latest` is an alias: OpenRouter always serves the newest Gemini Flash under it, so the project never names a model that gets retired. Swap in `~anthropic/claude-sonnet-latest` or `~openai/gpt-mini-latest` the same way. |
| `OPENROUTER_TEMPERATURE` | How freely the model words things. `0` gives the same answer to the same question; `0.3` gives the writing a little room. |
| `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): a rewrite takes more steps than a check. |

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 { resolve } from 'node:path';
import type { SiteClient } from '@fetchski/attract-cms-sdk';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { MemorySaver } from '@langchain/langgraph';
import { ChatOpenRouter } from '@langchain/openrouter';
import { CompositeBackend, createDeepAgent, FilesystemBackend, StateBackend } from 'deepagents';
import { todoListMiddleware } from 'langchain';
import { type SavedDraft, siteTools } from './tools.ts';

const REWRITE = `You give a small business website one voice. Over the years different people wrote its pages,
posts and FAQs, so they sound like different businesses. You agree with the owner how the business
sounds, and bring every document into that voice, with the owner approving each one.

The voice guide is /memories/voice-guide.md, your long-term memory: it is kept between runs, and loaded
into your agent memory at the start of each one.

Work in this order:
1. Plan your work with write_todos.
2. Call list_site_content once.
3. If your agent memory holds the voice guide, go to step 4. Otherwise call ask_owner: list the titles,
   and ask which two or three sound most like the business at its best. Read those documents with
   read_document, and write /memories/voice-guide.md: who "we" and "you" are, the tone, sentence length, words and phrases to use, words
   and phrases to avoid, punctuation habits, and three short before/after examples taken from the site.
4. Call ask_owner with the whole guide, and ask the owner to approve it or say what to change. Update
   /memories/voice-guide.md with edit_file and ask again until the owner approves it.
5. For every other document, one at a time, hand it to the rewriter subagent with the task tool: give
   its kind, id and title, and tell it the guide is at /memories/voice-guide.md. Never ask the owner
   about a document's changes yourself: the rewriter's save shows them, and the owner decides there.
6. When a rewriter reports a rule the owner gave, add it to /memories/voice-guide.md with edit_file.
7. Finish with a short summary: how many documents you rewrote, and which ones you left as they were.

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

const CHECK = `You check that a small business website still speaks in one voice: the one in the voice guide,
/memories/voice-guide.md, which the owner approved. It is loaded into your agent memory. Nobody is at the
keyboard for this run: you can't change the site, and you never change the guide.

Work in this order:
1. If your agent memory has no voice guide, answer only "There is no voice guide yet: run the agent at
   your keyboard first, with npm start." and stop.
2. Plan your work with write_todos.
3. Call list_site_content once.
4. Hand the documents to the checker subagent with the task tool, about eight at a time: give it the
   kind, id and title of each.
5. Finish with a report for the owner: a heading per document that drifts from the guide, with what
   drifts and a few of its words quoted. Then how many documents already fit.`;

const REWRITER = `You rewrite one website document in the business's own voice. Read /memories/voice-guide.md with
read_file, then the document with read_document. Rewrite only the wording that doesn't follow the guide.
Keep every fact exactly: names, prices, numbers, dates, addresses, links, and every HTML tag. Keep
headings about as long as they were. Then call save_draft_changes ONCE with every change, and the
document's title. If the owner sends it back with notes, revise your changes to match and call
save_draft_changes again. If the document already follows the guide, change nothing. Answer in one
line: what you changed, or that it already fit, then any rule the owner's notes gave, in their words.`;

const CHECKER = `You check website documents against the business's voice. Read /memories/voice-guide.md with
read_file, then each document you are given with read_document. Answer one line per document:
<kind> "<title>": fits, or <kind> "<title>": drifts, how, with a few of its words quoted.`;

export interface AgentOptions {
  /** Nobody is at the keyboard: the agent only checks and reports, with no tool to ask or to write. */
  reportOnly?: boolean;
  /** The folder on disk that keeps the agent's long-term memory (the voice guide) between runs. */
  memoryFolder?: string;
}

/** The deep agent: our SDK's operations as its tools, a subagent per document, 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, memoryFolder = 'memories' }: AgentOptions = {}
) {
  const tools = siteTools(site, saved, log);
  return createDeepAgent({
    model,
    systemPrompt: reportOnly ? CHECK : REWRITE,
    tools: reportOnly
      ? [tools.listSiteContent, tools.readDocument]
      : [tools.listSiteContent, tools.readDocument, tools.askOwner],
    middleware: [todoListMiddleware()],
    subagents: [
      reportOnly
        ? {
            name: 'checker',
            description:
              'Checks a batch of documents against /memories/voice-guide.md, one line each.',
            systemPrompt: CHECKER,
            tools: [tools.readDocument]
          }
        : {
            name: 'rewriter',
            description:
              'Rewrites one document in the voice of /memories/voice-guide.md and saves it as a draft, once the owner approves.',
            systemPrompt: REWRITER,
            tools: [tools.readDocument, tools.saveDraftChanges]
          }
    ],
    // Long-term memory: /memories/ is a folder on disk, kept between runs; every other file lives
    // for one run. The voice guide is loaded into the agent's instructions at the start of each run.
    backend: new CompositeBackend(new StateBackend(), {
      '/memories/': new FilesystemBackend({ rootDir: resolve(memoryFolder), virtualMode: true })
    }),
    memory: ['/memories/voice-guide.md'],
    // 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.3),
    maxTokens: Number(process.env.OPENROUTER_MAX_TOKENS || 16000)
  });
}
```

**Why:** `createDeepAgent()` gives the agent the filesystem tools (`write_file`, `read_file`,
`edit_file` and more) for the voice guide, the `task` tool to start a **rewriter** subagent per
document (each sees only the guide and its one document, so the main agent's context stays small
however big the site is), and `write_todos` from `todoListMiddleware()` for its plan. Only the
rewriter holds `save_draft_changes`. `interruptOn` pauses before every call to it, even from
inside the subagent, and the `MemorySaver` checkpointer keeps the run's state while it waits for
you. The `CompositeBackend` keeps the agent's working files for one run only, and routes
`/memories/` to the `memories` folder on disk: that is the long-term memory, as LangChain's
[memory guide](https://docs.langchain.com/oss/javascript/deepagents/memory) sets it up. The
`memory` option loads `/memories/voice-guide.md` into the agent's instructions at the start of each
run, so the guide you approved is there before it reads a document, and the next run shows it to
you instead of asking which pages to learn from. `reportOnly` builds an agent that only checks
documents against that guide, 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 rewriters can pause at
once, so each pause 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 = [`# One voice 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('One voice');
let summary: string;
try {
  summary = await runWithOwner(
    agent,
    'Give this whole website one voice, with me approving the voice guide and every document.',
    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 { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { factories, fakeSite } from '@fetchski/attract-cms-sdk/testing';
import { AIMessage, type BaseMessage } from '@langchain/core/messages';
import type { LLMResult } from '@langchain/core/outputs';
import { fakeModel } from 'langchain';
import { buildAgent } from '../src/agent.ts';
import { type Choice, type Draft, 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-returns:en:draft';
// A fresh memory folder, per test.
const memoryFolder = () => mkdtempSync(join(tmpdir(), 'memories-'));
const GUIDE = '# Our voice\n- Plain, warm, direct. "We" and "you".';

const STIFF = '<p>Customers are advised that returns are processed within 14 days.</p>';
const WARM = '<p>Changed your mind? Send it back within 14 days and we will sort it out.</p>';

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

// A site whose About page sounds like the owner, and whose FAQ sounds like a legal notice.
function siteWithTwoVoices() {
  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: 'Can I return something?' }],
      pages: 1,
      currentPage: 1
    }),
    'pages.getPage': factories.Page({
      id: PAGE_ID,
      title: 'About us',
      sections: richText('<p>We fix things properly, and we tell you straight.</p>')
    }),
    'faqItems.getFaqItem': factories.FaqItem({
      id: FAQ_ID,
      title: 'Can I return something?',
      answer: richText(STIFF)
    })
  });
}

// The moves a real model would make, scripted: the main agent's, then the rewriter subagent's, then the main agent's summary.
function scriptedModel() {
  return (
    fakeModel()
      .respondWithTools([{ name: 'list_site_content', args: {} }])
      .respondWithTools([
        {
          name: 'ask_owner',
          args: { question: 'Which pages sound most like you? About us, Can I return something?' }
        }
      ])
      .respondWithTools([{ name: 'read_document', args: { kind: 'page', id: PAGE_ID } }])
      .respondWithTools([
        {
          name: 'write_file',
          args: {
            file_path: '/memories/voice-guide.md',
            content: GUIDE
          }
        }
      ])
      .respondWithTools([
        {
          name: 'ask_owner',
          args: { question: 'Here is the voice guide: … Approve it, or tell me what to change.' }
        }
      ])
      .respondWithTools([
        {
          name: 'task',
          args: {
            subagent_type: 'rewriter',
            description: `Rewrite the faq ${FAQ_ID} "Can I return something?". The guide is at /memories/voice-guide.md.`
          }
        }
      ])
      // The rewriter subagent:
      .respondWithTools([{ name: 'read_file', args: { file_path: '/memories/voice-guide.md' } }])
      .respondWithTools([{ name: 'read_document', args: { kind: 'faq', id: FAQ_ID } }])
      .respondWithTools([
        {
          name: 'save_draft_changes',
          args: {
            kind: 'faq',
            id: FAQ_ID,
            title: 'Can I return something?',
            changes: [{ path: 'answer.items.0.content', before: STIFF, after: WARM }]
          }
        }
      ])
      .respond(new AIMessage('Rewrote the answer in the guide’s voice.'))
      // The main agent again:
      .respond(new AIMessage('Rewrote 1 document; About us already fit the voice.'))
  );
}

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

test('agrees the voice with the owner, then saves each approved rewrite as a draft', async () => {
  const site = siteWithTwoVoices();
  const saved = new Map<string, SavedDraft>();
  const owner = scriptedOwner(['About us', 'Approved'], [{ type: 'all' }]);
  const folder = memoryFolder();

  const summary = await runWithOwner(
    buildAgent(site, scriptedModel(), saved, () => {}, { memoryFolder: folder }),
    'Give this whole website one voice.',
    owner
  );

  strictEqual(summary, 'Rewrote 1 document; About us already fit the voice.');
  site.assertCalled('faqItems.updateFaqItem', (args) => {
    deepStrictEqual(args.answer, richText(WARM));
    return args.id === FAQ_ID;
  });
  site.assertNotCalled('pages.updatePage');
  deepStrictEqual([...saved], [[FAQ_ID, { kind: 'faq', title: 'Can I return something?' }]]);
  deepStrictEqual(
    owner.drafts.map((draft) => [draft.kind, draft.title, draft.changes.length]),
    [['faq', 'Can I return something?', 1]]
  );
  strictEqual(readFileSync(join(folder, 'voice-guide.md'), 'utf8'), GUIDE);
});

test('a rewrite the owner skips is never written', async () => {
  const site = siteWithTwoVoices();
  const owner = scriptedOwner(
    ['About us', 'Approved'],
    [{ type: 'skip', reason: 'Our returns answer has to stay formal' }]
  );

  await runWithOwner(
    buildAgent(site, scriptedModel(), new Map(), () => {}, { memoryFolder: memoryFolder() }),
    'Give this whole website one voice.',
    owner
  );

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

// The rewriter's moves for a run whose guide is already approved: one save, then whatever follows.
function rewriterModel(...after: Array<AIMessage | ((messages: BaseMessage[]) => AIMessage)>) {
  let model = fakeModel()
    .respondWithTools([{ name: 'list_site_content', args: {} }])
    .respondWithTools([
      {
        name: 'task',
        args: { subagent_type: 'rewriter', description: `Rewrite the faq ${FAQ_ID}.` }
      }
    ])
    .respondWithTools([{ name: 'read_document', args: { kind: 'faq', id: FAQ_ID } }])
    .respondWithTools([
      {
        name: 'save_draft_changes',
        args: {
          kind: 'faq',
          id: FAQ_ID,
          title: 'Can I return something?',
          changes: [
            { path: 'title', before: 'Can I return something?', after: 'Changed your mind?' },
            { path: 'answer.items.0.content', before: STIFF, after: WARM }
          ]
        }
      }
    ]);
  for (const step of after) {
    model = model.respond(step);
  }
  return model;
}

function withGuide() {
  const folder = memoryFolder();
  writeFileSync(join(folder, 'voice-guide.md'), GUIDE);
  return folder;
}

test('only the changes the owner keeps are written', async () => {
  const site = siteWithTwoVoices();
  const model = rewriterModel(
    new AIMessage('Rewrote the answer.'),
    new AIMessage('Rewrote 1 document.')
  );

  await runWithOwner(
    buildAgent(site, model, new Map(), () => {}, { memoryFolder: withGuide() }),
    'Give this whole website one voice.',
    scriptedOwner([], [{ type: 'some', keep: [1] }])
  );

  site.assertCalled('faqItems.updateFaqItem', (args) => {
    deepStrictEqual(args.answer, richText(WARM));
    strictEqual(args.title, undefined);
    return true;
  });
});

test('the owner sends a draft back with notes, and the agent revises it before it is saved', async () => {
  const site = siteWithTwoVoices();
  const SHORTER = '<p>Changed your mind? Send it back within 14 days.</p>';
  let notes = '';
  const model = rewriterModel(
    // The rewriter reads the owner's notes, then saves its revision.
    (messages) => {
      notes = String(messages.at(-1)?.content ?? '');
      return new AIMessage({
        content: '',
        tool_calls: [
          {
            id: 'revised',
            name: 'save_draft_changes',
            args: {
              kind: 'faq',
              id: FAQ_ID,
              title: 'Can I return something?',
              changes: [{ path: 'answer.items.0.content', before: STIFF, after: SHORTER }]
            }
          }
        ]
      });
    },
    new AIMessage('Rewrote the answer, shorter, as the owner asked. Rule: keep answers short.'),
    new AIMessage('Rewrote 1 document.')
  );
  const owner = scriptedOwner(
    [],
    [{ type: 'revise', feedback: 'Shorter, and keep the title' }, { type: 'all' }]
  );

  await runWithOwner(
    buildAgent(site, model, new Map(), () => {}, { memoryFolder: withGuide() }),
    'Give this whole website one voice.',
    owner
  );

  ok(notes.includes('Shorter, and keep the title'));
  strictEqual(owner.drafts.length, 2);
  site.assertCalled('faqItems.updateFaqItem', (args) => {
    deepStrictEqual(args.answer, richText(SHORTER));
    return true;
  });
});

test('two rewriters that pause at once each get their own decision', async () => {
  const OTHER_ID = 'faq-shipping:en:draft';
  const site = siteWithTwoVoices();
  // One rewriter step, for whichever rewriter asks: read its FAQ, save it, then answer.
  const rewriter = (messages: BaseMessage[]) => {
    const id = String(messages.find((message) => message.type === 'human')?.content).includes(
      OTHER_ID
    )
      ? OTHER_ID
      : FAQ_ID;
    const done = messages.filter((message) => message.type === 'tool').length;
    if (done === 0) {
      return new AIMessage({
        content: '',
        tool_calls: [{ id: `read-${id}`, name: 'read_document', args: { kind: 'faq', id } }]
      });
    }
    if (done === 1) {
      return new AIMessage({
        content: '',
        tool_calls: [
          {
            id: `save-${id}`,
            name: 'save_draft_changes',
            args: {
              kind: 'faq',
              id,
              title: id,
              changes: [{ path: 'answer.items.0.content', before: STIFF, after: WARM }]
            }
          }
        ]
      });
    }
    return new AIMessage(`Done with ${id}.`);
  };
  const model = fakeModel()
    .respondWithTools([{ name: 'list_site_content', args: {} }])
    .respondWithTools(
      [FAQ_ID, OTHER_ID].map((id) => ({
        name: 'task',
        args: { subagent_type: 'rewriter', description: `Rewrite the faq ${id}.` }
      }))
    )
    .respond(rewriter)
    .respond(rewriter)
    .respond(rewriter)
    .respond(rewriter)
    .respond(rewriter)
    .respond(rewriter)
    .respond(new AIMessage('Rewrote 1 document.'));
  // The owner saves the first draft they see and skips the second.
  const owner = scriptedOwner([], [{ type: 'all' }, { type: 'skip', reason: '' }]);

  await runWithOwner(
    buildAgent(site, model, new Map(), () => {}, { memoryFolder: withGuide() }),
    'Give this whole website one voice.',
    owner
  );

  strictEqual(owner.drafts.length, 2);
  const [first, second] = owner.drafts.map((draft) => draft.title);
  site.assertCalled('faqItems.updateFaqItem', (args) => args.id === first);
  ok(second && second !== first);
  site.assertCalledTimes('faqItems.updateFaqItem', 1);
});

test('a report-only run with no voice guide stops, and never asks or writes', async () => {
  const site = siteWithTwoVoices();
  const model = fakeModel().respond(
    new AIMessage(
      'There is no voice guide yet: run the agent at your keyboard first, with npm start.'
    )
  );

  const summary = await runWithOwner(
    buildAgent(site, model, new Map(), () => {}, {
      reportOnly: true,
      memoryFolder: memoryFolder()
    }),
    'Check every document against our voice guide.',
    nobody
  );

  strictEqual(summary.startsWith('There is no voice guide yet'), true);
  ok(model.calls[0]?.messages[0]?.text.includes('(No memory loaded)'));
  site.assertNotCalled('faqItems.updateFaqItem');
});

test('a report-only run checks the documents against the guide in its memory', async () => {
  const site = siteWithTwoVoices();
  const folder = memoryFolder();
  writeFileSync(join(folder, 'voice-guide.md'), GUIDE);
  const model = fakeModel()
    .respondWithTools([{ name: 'list_site_content', args: {} }])
    .respondWithTools([
      {
        name: 'task',
        args: {
          subagent_type: 'checker',
          description: `Check page ${PAGE_ID} "About us" and faq ${FAQ_ID} "Can I return something?".`
        }
      }
    ])
    // The checker subagent:
    .respondWithTools([{ name: 'read_file', args: { file_path: '/memories/voice-guide.md' } }])
    .respondWithTools([
      { name: 'read_document', args: { kind: 'page', id: PAGE_ID } },
      { name: 'read_document', args: { kind: 'faq', id: FAQ_ID } }
    ])
    .respond(
      new AIMessage(
        'page "About us": fits\nfaq "Can I return something?": drifts, stiff: "Customers are advised"'
      )
    )
    // The main agent again:
    .respond(new AIMessage('## Can I return something?\nStiff: "Customers are advised". 1 fits.'));

  const summary = await runWithOwner(
    buildAgent(site, model, new Map(), () => {}, { reportOnly: true, memoryFolder: folder }),
    'Check every document against our voice guide.',
    nobody
  );

  strictEqual(summary, '## Can I return something?\nStiff: "Customers are advised". 1 fits.');
  // The guide was in the agent's instructions from the start: its long-term memory.
  ok(model.calls[0]?.messages[0]?.text.includes(GUIDE));
  site.assertCalled('faqItems.getFaqItem', (args) => args.id === FAQ_ID);
  site.assertNotCalled('faqItems.updateFaqItem');
});

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'));
});

test("a run's usage counts the subagents' model calls too", async () => {
  const site = siteWithTwoVoices();
  const usage = new Usage();
  const used = (message: AIMessage) => {
    message.usage_metadata = { input_tokens: 100, output_tokens: 10, total_tokens: 110 };
    return message;
  };
  const model = fakeModel()
    .respond(
      used(
        new AIMessage({
          content: '',
          tool_calls: [
            {
              id: 't',
              name: 'task',
              args: { subagent_type: 'rewriter', description: `Rewrite the faq ${FAQ_ID}.` }
            }
          ]
        })
      )
    )
    // The rewriter subagent:
    .respond(used(new AIMessage('It already fits.')))
    // The main agent again:
    .respond(used(new AIMessage('Rewrote 0 documents.')));

  await runWithOwner(
    buildAgent(site, model, new Map(), () => {}, { memoryFolder: withGuide() }),
    'Give this whole website one voice.',
    scriptedOwner([], []),
    usage
  );

  strictEqual(usage.calls, 3);
  strictEqual(usage.inputTokens, 300);
  strictEqual(usage.outputTokens, 30);
});
```

**Why:** the tests run the whole agent, its subagents included, with no site and no model. `fakeSite()` stands in
for your site and answers every SDK call, and LangChain's `fakeModel()` plays the model's moves in
order. Nothing is sent, written or spent.

## Your project

When you're done, `one-voice` 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**

**memories**

**voice-guide.md**

**.env**

**.gitignore**

**.npmrc**

**package.json**

**package-lock.json**

**tsconfig.json**

`memories/voice-guide.md` appears after your first run, once you approve the guide. Keep it: the
next run starts from it. You can edit it by hand too; delete it to start over.

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"}
✔ agrees the voice with the owner, then saves each approved rewrite as a draft (91.049541ms)
✔ a rewrite the owner skips is never written (47.144291ms)
✔ only the changes the owner keeps are written (26.33075ms)
✔ the owner sends a draft back with notes, and the agent revises it before it is saved (28.272667ms)
✔ two rewriters that pause at once each get their own decision (26.166416ms)
✔ a report-only run with no voice guide stops, and never asks or writes (7.2205ms)
✔ a report-only run checks the documents against the guide in its memory (18.974333ms)
✔ usage adds up tokens and the cost OpenRouter reports for each call (7.952958ms)
✔ a run's usage counts the subagents' model calls too (11.918417ms)
ℹ tests 9
ℹ pass 9
ℹ 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"}
┌   One voice
│
◇  Found 23 documents  41,872 tokens · $0.0093
│
◆  Which two or three of these sound most like you at your best?
│
│  1. Home
│
│  2. About us
│
│  3. What to expect on your first visit (a blog post)
│
◇  Your answer
│  About us and the post What to expect on your first visit
│
◇  Read the page "About us" · Read the post "What to expect on your first visit"  98,410 tokens · $0.0214
│
◆  Here is the voice guide. Approve it, or tell me what to change.
│
│  We are "we"; the reader is "you". Never "customers" or "clients".
│
│  Warm and direct: short sentences, one idea each. Say what happens next.
│
◇  Your answer
│  Approved
│
◇  Read the faq "Can I return something?"  163,905 tokens · $0.0371
│
◆  Save a draft of the faq "Can I return something?"?
│
│  1. answer.items.0.content
│     before <p>Customers are advised that returns are processed within 14 days.</p>
│     after  <p>Changed your mind? Send it back within 14 days and we'll sort it out.</p>
│
◇  Your call
│  Save it
│
│  …
│
◆  One voice on acme.example, 2026-10-28
│
│  I rewrote 17 documents in your voice. Home, About us and Contact already fit, so I left them alone.
│
│  Drafts waiting for you
│
│  Publish each one in the CMS when it reads right.
│
│  • faq "Can I return something?"
│
│  • page "Services", preview: https://acme.example/services?aposShareKey=k8Jq2w&aposShareId=ckq3v8x5b0004%3Aen%3Adraft
│
│  Usage
│
│  212 model calls · 1,904,377 tokens in · 61,208 out · $0.4471. The cost is what OpenRouter charged for this run.
│
└  Done: 17 drafts saved · 212 model calls · 1,904,377 tokens in · 61,208 out · $0.4471
```

Between the progress lines the agent may think for a minute: it is reading, or writing the guide.
On later runs it shows you the guide in `memories/voice-guide.md` instead of asking which pages to learn from.
Answer the guide's question with what to change ("less formal, and never use exclamation marks"),
and it rewrites the guide and asks again. 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 ("buttons stay one to three words"). The
  agent revises the draft and shows it again, and a rule you give goes into the voice guide.
* **Skip this one** writes nothing; the agent reads your reason and leaves that document alone.

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

Then open each preview link, 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 400 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: the
> documents already in your voice come back unchanged. Or raise `AGENT_MAX_STEPS` (more steps, more
> cost).

## Going further

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

### Save each report to a file

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

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

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

**Why:** writes the report to `reports/` as a Markdown file named by the date and time, such as
`reports/2026-10-28-0900.md`. Set `REPORTS_FOLDER` in `.env` to keep them somewhere else.

**2. Save it at the end of src/main.ts**

In `src/main.ts`, add the `save-report.ts` import, add `note` to the import from `./terminal.ts`,
and put the last line just above the `outro(…)` at the end:

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

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

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

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

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

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

**You should see,** at the end of your next run: `Saved to reports/2026-10-28-0900.md.`

### Email it to you

Pick one way to send it, then add it to `src/main.ts`. Both send the same email and need the
same two addresses in `.env`.

> [accordion] Nodemailer: through any mail server (SMTP)
>
> **1. Install the mail packages**
>
> ```bash npm theme={"dark"}
> npm install nodemailer
> npm install --save-dev @types/nodemailer
> ```
>
> ```bash pnpm theme={"dark"}
> pnpm add nodemailer
> pnpm add --save-dev @types/nodemailer
> ```
>
> ```bash bun theme={"dark"}
> bun add nodemailer
> bun add --dev @types/nodemailer
> ```
>
> **Why:** [Nodemailer](https://nodemailer.com) sends email through any mail server, and
> [marked](https://marked.js.org), already in your project for the terminal, turns the Markdown
> report into the email's formatted version.
>
> **2. Add your mail server to .env**
>
> Your email provider shows its SMTP settings: a host, a port, a user name and a password (often
> one made for apps). Put them together as a URL:
>
> ```ini .env theme={"dark"}
> # Email the report: your mail server, who sends it, and who gets it
> SMTP_URL="smtps://reports%40acme.example:app-password@smtp.acme.example:465"
> REPORT_EMAIL_FROM="Website reports <reports@acme.example>"
> REPORT_EMAIL_TO="you@acme.example"
> ```
>
> `smtps://` with port `465` is a secure connection from the start; for port `587`, write
> `smtp://` (the connection is secured once it opens). In the user name and the password, write
> `@` as `%40`, `:` as `%3A` and `/` as `%2F`. Send to several people with commas:
> `REPORT_EMAIL_TO="you@acme.example,partner@acme.example"`.
>
> Keep the double quotes around each value: the sender's name has spaces and `<` `>` in it, and
> quotes keep a value whole.
>
> **3. Add src/email-report.ts**
>
> ```ts src/email-report.ts theme={"dark"}
> import { marked } from 'marked';
> import { createTransport } from 'nodemailer';
>
> /**
>  * Emails the report to REPORT_EMAIL_TO, through the mail server in SMTP_URL. Its first line (the
>  * report's heading) is the subject. Answers what happened, in words: a run never fails over email.
>  */
> export async function emailReport(report: string): Promise<string> {
>   const { SMTP_URL, REPORT_EMAIL_FROM, REPORT_EMAIL_TO } = process.env;
>   if (!SMTP_URL || !REPORT_EMAIL_FROM || !REPORT_EMAIL_TO) {
>     return 'Not emailed: set SMTP_URL, REPORT_EMAIL_FROM and REPORT_EMAIL_TO in .env.';
>   }
>   try {
>     await createTransport(SMTP_URL).sendMail({
>       from: REPORT_EMAIL_FROM,
>       to: REPORT_EMAIL_TO,
>       subject: report.split('\n')[0]?.replace(/^#\s*/, '') ?? 'Your website report',
>       text: report,
>       html: await marked.parse(report)
>     });
>     return `Emailed to ${REPORT_EMAIL_TO}.`;
>   } catch (error) {
>     return `Not emailed: ${error instanceof Error ? error.message : String(error)}`;
>   }
> }
> ```
>
> **Why:** sends the report with its heading as the subject, as plain text and as formatted HTML.
> If the mail server refuses, the run still ends normally and says why: the report is in
> `reports/` either way.

> [accordion] Postmark: through your Postmark account
>
> **1. Install the mail packages**
>
> ```bash npm theme={"dark"}
> npm install postmark
> ```
>
> ```bash pnpm theme={"dark"}
> pnpm add postmark
> ```
>
> ```bash bun theme={"dark"}
> bun add postmark
> ```
>
> **Why:** [Postmark's Node.js library](https://postmarkapp.com/developer/integration/official-libraries#node-js)
> sends email through your Postmark account's API (it brings its own types), and
> [marked](https://marked.js.org), already in your project for the terminal, turns the Markdown
> report into the email's formatted version.
>
> **2. Add your Postmark token to .env**
>
> In Postmark, open your server, then **API Tokens**, and copy its **Server API token**. The
> sending address must be a confirmed sender signature or on a verified domain in that account:
>
> ```ini .env theme={"dark"}
> # Email the report: your Postmark server token, who sends it, and who gets it
> POSTMARK_SERVER_TOKEN="your-server-api-token"
> REPORT_EMAIL_FROM="Website reports <reports@acme.example>"
> REPORT_EMAIL_TO="you@acme.example"
> ```
>
> Send to several people with commas: `REPORT_EMAIL_TO="you@acme.example,partner@acme.example"`.
>
> Keep the double quotes around each value: the sender's name has spaces and `<` `>` in it, and
> quotes keep a value whole.
>
> **3. Add src/email-report.ts**
>
> ```ts src/email-report.ts theme={"dark"}
> import { marked } from 'marked';
> import { ServerClient } from 'postmark';
>
> /**
>  * Emails the report to REPORT_EMAIL_TO, through Postmark. Its first line (the report's heading)
>  * is the subject. Answers what happened, in words: a run never fails over email.
>  */
> export async function emailReport(report: string): Promise<string> {
>   const { POSTMARK_SERVER_TOKEN, REPORT_EMAIL_FROM, REPORT_EMAIL_TO } = process.env;
>   if (!POSTMARK_SERVER_TOKEN || !REPORT_EMAIL_FROM || !REPORT_EMAIL_TO) {
>     return 'Not emailed: set POSTMARK_SERVER_TOKEN, REPORT_EMAIL_FROM and REPORT_EMAIL_TO in .env.';
>   }
>   try {
>     await new ServerClient(POSTMARK_SERVER_TOKEN).sendEmail({
>       From: REPORT_EMAIL_FROM,
>       To: REPORT_EMAIL_TO,
>       Subject: report.split('\n')[0]?.replace(/^#\s*/, '') ?? 'Your website report',
>       TextBody: report,
>       HtmlBody: await marked.parse(report)
>     });
>     return `Emailed to ${REPORT_EMAIL_TO}.`;
>   } catch (error) {
>     return `Not emailed: ${error instanceof Error ? error.message : String(error)}`;
>   }
> }
> ```
>
> **Why:** sends the report with its heading as the subject, as plain text and as formatted HTML.
> If Postmark refuses (a wrong token, an unconfirmed sender), the run still ends normally and says
> why: the report is in `reports/` either way.

**1. Email it at the end of src/main.ts**

Add the import at the top of `src/main.ts`, and the last line under the one that saves the
report, still above the `outro(…)`:

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

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

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

**You should see,** at the end of your next run: `Emailed to you@acme.example.`, and the report
in your inbox a moment later.

### Run it every week

A scheduled run has nobody at the keyboard, so it can't ask you anything or wait for your yes. It
runs in **report-only** mode instead: it checks every document against the voice guide you approved,
and lists the ones that drift. 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 checks every document against the voice
// guide you approved, and reports the ones that drift. 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 every document on this website against our voice guide, and report the ones that drift.',
    nobody,
    usage
  );
  report = reportMarkdown(
    `${summary}\n\nTo rewrite 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 starts with `memories/voice-guide.md` in
its instructions and checks every document against it, never changing the guide. With no guide yet (you haven't approved one at your keyboard), it says
so and stops. The report is printed, saved and emailed. If the run stops (a wrong key, a
missing `.env` value, no OpenRouter credit left), you still get an email, with "the check
stopped" in its subject and the reason in it.

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

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

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

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

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

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

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

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

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

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

**3. Schedule it**

These steps are for macOS and Linux, with `cron`. The computer has to be on and awake at the
time, so an always-on machine or a small server is best.

Find the folder Node is in:

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

Open your schedule with `crontab -e`, and add these two lines. Put the folder you just found
first on the `PATH` line, and your project's full path after `cd`:

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

`0 8 * * 1` runs it every Monday at 8:00: minute 0, hour 8, any day of the month, any month,
Monday (`1`). `cron` doesn't load your shell's settings, which is why the `PATH` line names the
folder Node is in.

On macOS, keep the project out of **Desktop**, **Documents** and **Downloads**: a scheduled job
can't open those folders unless you give `cron` **Full Disk Access** in **System Settings** →
**Privacy & Security**.

**You should see,** every Monday morning, an email like this (the report is the agent's own
words, so yours will differ):

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

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

## Can I return something?
Stiff and formal, where the guide says warm and direct: "Customers are advised that returns are
processed within 14 days."

19 documents already fit the voice.

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

## Usage

41 model calls · 388,240 tokens in · 7,512 out · $0.0857. The cost is what OpenRouter charged for this run.
```

The same report is in `reports/`, named by its date and time.

**No email on Monday?** Open `reports/run.log` in the project: every run adds its output there,
starting with `Report-only run,` and its date and time. An entry that ends `Not emailed:` says
what the mail server or Postmark answered. No entry for that Monday means the run never started: the
computer was asleep or off at 8:00, or the `cd` path or the `PATH` line in `crontab -e` is
wrong.

## Turn it off

The agent runs only when you start it, or when your schedule does. To retire it:

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

Delete its line in `crontab -e`.

**2. Revoke the site key**

In the CMS, **⌘K** → **API Keys**, open **One voice**, 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] [Find where your website contradicts itself](https://docs.fetchski-attract.com/recipes/deep-agents-contradictions.md)
>
> The same tools, checking every fact your site states twice

> [card] [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: memory](https://docs.langchain.com/oss/javascript/deepagents/memory)
>
> Long-term memory as files, in LangChain's docs

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


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