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

# Get a brief in Slack when a competitor changes its pricing

> Monitors watch competitors' pricing pages; on a real change an agent reads the page, compares it with the old plans and posts what it means.

export const OutcrawlStyles = () => {
  if (typeof document !== 'undefined' && !document.getElementById('outcrawl-overrides')) {
    const s = document.createElement('style');
    s.id = 'outcrawl-overrides';
    s.textContent = `
      /* Match the product's radius grid (4 / 6 / 8 / 9999), not Mintlify's default. */
      .rounded-sm { border-radius: 0.375rem !important; } /* 6px, --r-ctl */
      .rounded-md { border-radius: 0.375rem !important; } /* 6px, --r-ctl */
      .rounded-lg { border-radius: 0.5rem !important; }   /* 8px, --r-panel */
      .rounded-l-sm { border-top-left-radius: 0.375rem !important; border-bottom-left-radius: 0.375rem !important; }
      .rounded-r-sm { border-top-right-radius: 0.375rem !important; border-bottom-right-radius: 0.375rem !important; }
      .rounded-l-md { border-top-left-radius: 0.375rem !important; border-bottom-left-radius: 0.375rem !important; }
      .rounded-r-md { border-top-right-radius: 0.375rem !important; border-bottom-right-radius: 0.375rem !important; }
      .rounded-l-lg { border-top-left-radius: 0.5rem !important; border-bottom-left-radius: 0.5rem !important; }
      .rounded-r-lg { border-top-right-radius: 0.5rem !important; border-bottom-right-radius: 0.5rem !important; }

      /* Cards: never pure white in light mode, matching the one-big-card surface tone. */
      .card { background-color: #fcfcfc !important; border-radius: 0.5rem !important; }
      html.dark .card { background-color: #141311 !important; }

      /* Docs hero box */
      .oc-hero { background-color: #fcfcfc; border: 1px solid #e5e2dc; }
      html.dark .oc-hero { background-color: #141311; border-color: #26241f; }
      html.dark .oc-hero h1 { color: #edeae4; }

      /* Thin, transparent-track, hide-until-hover scrollbar. */
      ::-webkit-scrollbar { width: 6px; height: 6px; background-color: transparent; }
      ::-webkit-scrollbar-track { background-color: transparent; }
      ::-webkit-scrollbar-thumb { background-color: rgba(155, 155, 155, 0.5); border-radius: 10px; transition: opacity 0.3s ease; opacity: 0; }
      ::-webkit-scrollbar-thumb:hover { background-color: rgba(155, 155, 155, 0.7); }
      *:hover::-webkit-scrollbar-thumb,
      *:focus::-webkit-scrollbar-thumb,
      *:active::-webkit-scrollbar-thumb { opacity: 1; }
      * { scrollbar-width: thin; scrollbar-color: rgba(155, 155, 155, 0.5) transparent; }
    `;
    document.head.appendChild(s);
  }
  return null;
};

<OutcrawlStyles />

A competitor moves a seat limit from the Team plan to Business on a Tuesday afternoon, and your sales team finds out from a prospect three weeks later. Checking pricing pages by hand is the job everyone agrees matters and nobody does. This program watches each pricing page, and when one really changes, an agent reads the new page and its fine print, lines it up against the plans as they were, and posts a brief to Slack: what changed, plan by plan, and what it means for your own pricing.

## What you need

* An Outcrawl API key in `OUTCRAWL_API_KEY`.
* A Slack [incoming webhook](https://api.slack.com/messaging/webhooks) URL in `SLACK_WEBHOOK_URL`. Posting to it is plain HTTP from your code, so no Outcrawl integration is involved.
* A server reachable from the internet (the monitors post to it), and a long random string in `HOOK_TOKEN` for its path.
* Node with `express`, or Python with `fastapi`, `uvicorn` and `httpx`.

## How it works

<Steps>
  <Step title="A monitor watches each pricing page">
    [`monitors.create`](/docs/monitors/overview) checks each page every 30 minutes. Timestamps, nonces and ads are stripped before comparing, `ignoreSelectors` drops the rotating testimonials, and each check is 1 credit whether or not anything changed.
  </Step>

  <Step title="A real change posts to your endpoint">
    The monitor sends `monitorId`, `url`, `label`, `checkedAt`, the `diff` and, with `judge: true`, a one-line `summary`. It sends once and never retries, so the endpoint answers `202` at once and does the work afterwards.
  </Step>

  <Step title="Your endpoint starts an agent run">
    A monitor cannot start an agent itself; your code calls [`oc.agent`](/docs/agents/overview) with the diff and the plans recorded after the last change. [Rules](/docs/account/rules) keep the run on that competitor's site and stop it signing up for anything.
  </Step>

  <Step title="The agent reads the page and writes the brief">
    It reads the live page and the comparison table, FAQ and fine print it links to, and answers in a [schema](/docs/agents/overview#typed-results): every plan with prices and limits, each change with before and after, what it means, and one suggested response.
  </Step>

  <Step title="Your code posts it to Slack">
    The brief goes to Slack as Block Kit, with the run's [replay](/docs/browsers/sessions) so anyone on your team can check what the agent read. The plan table is saved as the baseline for the next change.
  </Step>
</Steps>

## The program

Run it once with `watch` to create the monitors, then run it as a server.

<CodeGroup>
  ```typescript TypeScript theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
  import express from 'express';
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
  import { Outcrawl } from '@outcrawl/sdk';

  const oc = new Outcrawl(); // reads OUTCRAWL_API_KEY

  const PUBLIC_URL = 'https://yourapp.example'; // where this server is reachable
  const HOOK = `/hooks/pricing/${process.env.HOOK_TOKEN}`; // the path is the credential: long and random
  const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL ?? ''; // a Slack incoming webhook
  const OUR_PRICING = 'Team $29 per seat per month, Business $59 per seat per month, annual billing saves 20%.';

  const COMPETITORS = [
    { label: 'pricing-northwind', url: 'https://northwind.example/pricing' },
    { label: 'pricing-fabrikam', url: 'https://fabrikam.example/pricing' },
    { label: 'pricing-tailspin', url: 'https://www.tailspin.example/plans' },
  ];

  interface Plan { name: string; monthly: string; annual: string; limits: string[] }
  interface Brief {
    headline: string;
    changes: { plan: string; what: string; before: string; after: string }[];
    plans: Plan[];
    whatItMeans: string;
    suggestedResponse: string;
  }

  // What a monitor POSTs to your endpoint.
  interface Change {
    monitorId: string;
    url: string;
    label?: string;
    status: 'changed' | 'removed';
    checkedAt: string;
    previousCheckedAt?: string;
    diff?: string;
    summary?: string;
  }

  const briefSchema = {
    type: 'object',
    properties: {
      headline: { type: 'string', description: 'One sentence a salesperson can repeat.' },
      changes: {
        type: 'array',
        items: {
          type: 'object',
          properties: { plan: { type: 'string' }, what: { type: 'string' }, before: { type: 'string' }, after: { type: 'string' } },
          required: ['plan', 'what', 'before', 'after'],
        },
      },
      plans: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            name: { type: 'string' },
            monthly: { type: 'string' },
            annual: { type: 'string' },
            limits: { type: 'array', items: { type: 'string' } },
          },
          required: ['name', 'monthly', 'annual', 'limits'],
        },
      },
      whatItMeans: { type: 'string', description: 'Two or three sentences on what this means for us.' },
      suggestedResponse: { type: 'string', description: 'One concrete thing we could do this week.' },
    },
    required: ['headline', 'changes', 'plans', 'whatItMeans', 'suggestedResponse'],
  };

  async function slack(text: string, blocks?: unknown[]): Promise<void> {
    await fetch(SLACK_WEBHOOK_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text, blocks }),
    });
  }

  async function brief(change: Change): Promise<void> {
    const key = change.label ?? change.monitorId;
    const file = `./pricing-state/${key}.json`;
    const before = await readFile(file, 'utf8').catch(() => 'none on record yet');

    const run = await oc.agent({
      task: `The pricing page ${change.url} changed at ${change.checkedAt}.
  The plans as we last recorded them: ${before}
  The change the monitor saw, as a diff of the page (lines starting "-" were removed, "+" were added):
  ${(change.diff ?? 'no diff available').slice(0, 8000)}
  Read the page as it is now, including any plan comparison table, FAQ and fine print it links to on the same site.
  List every plan with its monthly and annual price and its limits. List what changed against the old plans.
  Then explain what the change means for a company that sells: ${OUR_PRICING}`,
      schema: briefSchema,
      caps: { budget: '100.00', duration: '5m' },
      rules: [`Only use ${new URL(change.url).hostname.replace(/^www\./, '')}.`, 'Never sign up for an account.'],
    });

    const report = run.data as Brief | undefined;
    if (run.status !== 'completed' || report === undefined) {
      await slack(`${key} changed, but the brief did not finish (${run.stopReason ?? run.status}). Replay: ${run.replayUrl ?? 'none'}`);
      return;
    }
    await mkdir('./pricing-state', { recursive: true });
    await writeFile(file, JSON.stringify(report.plans));

    const changes = report.changes.map((c) => `• *${c.plan}*: ${c.what} (was ${c.before}, now ${c.after})`).join('\n');
    await slack(report.headline, [
      { type: 'header', text: { type: 'plain_text', text: report.headline.slice(0, 150) } },
      { type: 'section', text: { type: 'mrkdwn', text: changes || 'No plan-level change found.' } },
      { type: 'section', text: { type: 'mrkdwn', text: `*What it means*\n${report.whatItMeans}` } },
      { type: 'section', text: { type: 'mrkdwn', text: `*Suggested response*\n${report.suggestedResponse}` } },
      {
        type: 'context',
        elements: [{ type: 'mrkdwn', text: `<${change.url}|${key}> · ${run.replayUrl ? `<${run.replayUrl}|replay> · ` : ''}${run.credits} credits` }],
      },
    ]);
  }

  if (process.argv[2] === 'watch') {
    // One-off setup: a monitor per pricing page, skipping any that already exist.
    const existing = await oc.monitors.list({ label: 'pricing-*' });
    for (const { label, url } of COMPETITORS) {
      if (existing.some((m) => m.url === url)) continue;
      const monitor = await oc.monitors.create({
        url,
        label,
        schedule: '*/30 * * * *', // every 30 minutes
        judge: true, // a model summarises the diff into `summary`
        normalise: { ignoreSelectors: ['[class*="testimonial"]', '[class*="carousel"]'] },
        notify: { webhook: `${PUBLIC_URL}${HOOK}` },
      });
      console.log('watching', url, monitor.id);
    }
  } else {
    const app = express();
    app.use(express.json({ limit: '2mb' }));
    app.post(HOOK, (req, res) => {
      res.sendStatus(202); // answer at once: a monitor delivers each change once and never retries
      brief(req.body as Change).catch((err) => console.error('brief failed', err));
    });
    app.listen(3000, () => console.log('listening on :3000'));
  }
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
  import asyncio
  import json
  import os
  from pathlib import Path
  from urllib.parse import urlparse

  import httpx
  from fastapi import BackgroundTasks, FastAPI, Request
  from outcrawl import Outcrawl

  oc = Outcrawl()  # one client for the life of the server; reads OUTCRAWL_API_KEY
  app = FastAPI()

  PUBLIC_URL = "https://yourapp.example"  # where this server is reachable
  HOOK = f"/hooks/pricing/{os.environ.get('HOOK_TOKEN', '')}"  # the path is the credential: long and random
  SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL", "")  # a Slack incoming webhook
  OUR_PRICING = "Team $29 per seat per month, Business $59 per seat per month, annual billing saves 20%."
  STATE = Path("pricing-state")

  COMPETITORS = [
      {"label": "pricing-northwind", "url": "https://northwind.example/pricing"},
      {"label": "pricing-fabrikam", "url": "https://fabrikam.example/pricing"},
      {"label": "pricing-tailspin", "url": "https://www.tailspin.example/plans"},
  ]

  BRIEF_SCHEMA = {
      "type": "object",
      "properties": {
          "headline": {"type": "string", "description": "One sentence a salesperson can repeat."},
          "changes": {
              "type": "array",
              "items": {
                  "type": "object",
                  "properties": {
                      "plan": {"type": "string"},
                      "what": {"type": "string"},
                      "before": {"type": "string"},
                      "after": {"type": "string"},
                  },
                  "required": ["plan", "what", "before", "after"],
              },
          },
          "plans": {
              "type": "array",
              "items": {
                  "type": "object",
                  "properties": {
                      "name": {"type": "string"},
                      "monthly": {"type": "string"},
                      "annual": {"type": "string"},
                      "limits": {"type": "array", "items": {"type": "string"}},
                  },
                  "required": ["name", "monthly", "annual", "limits"],
              },
          },
          "whatItMeans": {"type": "string", "description": "Two or three sentences on what this means for us."},
          "suggestedResponse": {"type": "string", "description": "One concrete thing we could do this week."},
      },
      "required": ["headline", "changes", "plans", "whatItMeans", "suggestedResponse"],
  }


  async def slack(text: str, blocks: list | None = None) -> None:
      async with httpx.AsyncClient() as http:
          await http.post(SLACK_WEBHOOK_URL, json={"text": text, "blocks": blocks})


  async def brief(change: dict) -> None:
      key = change.get("label") or change["monitorId"]
      file = STATE / f"{key}.json"
      before = file.read_text() if file.exists() else "none on record yet"
      diff = (change.get("diff") or "no diff available")[:8000]

      run = await oc.agent(
          task=f"""The pricing page {change['url']} changed at {change['checkedAt']}.
  The plans as we last recorded them: {before}
  The change the monitor saw, as a diff of the page (lines starting "-" were removed, "+" were added):
  {diff}
  Read the page as it is now, including any plan comparison table, FAQ and fine print it links to on the same site.
  List every plan with its monthly and annual price and its limits. List what changed against the old plans.
  Then explain what the change means for a company that sells: {OUR_PRICING}""",
          schema=BRIEF_SCHEMA,
          caps={"budget": "100.00", "duration": "5m"},
          rules=[f"Only use {urlparse(change['url']).hostname.removeprefix('www.')}.", "Never sign up for an account."],
      )

      report = run.get("data")
      if run["status"] != "completed" or not report:
          reason = run.get("stopReason") or run["status"]
          await slack(f"{key} changed, but the brief did not finish ({reason}). Replay: {run.get('replayUrl') or 'none'}")
          return
      STATE.mkdir(exist_ok=True)
      file.write_text(json.dumps(report["plans"]))

      changes = "\n".join(f"• *{c['plan']}*: {c['what']} (was {c['before']}, now {c['after']})" for c in report["changes"])
      replay = f"<{run['replayUrl']}|replay> · " if run.get("replayUrl") else ""
      await slack(report["headline"], [
          {"type": "header", "text": {"type": "plain_text", "text": report["headline"][:150]}},
          {"type": "section", "text": {"type": "mrkdwn", "text": changes or "No plan-level change found."}},
          {"type": "section", "text": {"type": "mrkdwn", "text": f"*What it means*\n{report['whatItMeans']}"}},
          {"type": "section", "text": {"type": "mrkdwn", "text": f"*Suggested response*\n{report['suggestedResponse']}"}},
          {"type": "context", "elements": [
              {"type": "mrkdwn", "text": f"<{change['url']}|{key}> · {replay}{run['credits']} credits"},
          ]},
      ])


  @app.post(HOOK, status_code=202)
  async def pricing_changed(request: Request, background: BackgroundTasks) -> dict:
      # Answer at once: a monitor delivers each change once and never retries.
      background.add_task(brief, await request.json())
      return {"accepted": True}


  async def watch() -> None:
      # One-off setup: a monitor per pricing page, skipping any that already exist.
      existing = {m["url"] for m in await oc.monitors.list(label="pricing-*")}
      for competitor in COMPETITORS:
          if competitor["url"] in existing:
              continue
          monitor = await oc.monitors.create(
              url=competitor["url"],
              label=competitor["label"],
              schedule="*/30 * * * *",  # every 30 minutes
              judge=True,  # a model summarises the diff into `summary`
              normalise={"ignoreSelectors": ['[class*="testimonial"]', '[class*="carousel"]']},
              notify={"webhook": f"{PUBLIC_URL}{HOOK}"},
          )
          print("watching", competitor["url"], monitor["id"])
      await oc.aclose()


  if __name__ == "__main__":
      asyncio.run(watch())  # python pricing_brief.py, once; then serve with: uvicorn pricing_brief:app --port 3000
  ```
</CodeGroup>

Outcrawl keeps no history of a monitor's checks: `monitors.list` answers each monitor's `lastCheckedAt` and `lastStatus`, not old diffs. That is why the program saves the plan table itself in `pricing-state/` and hands the last one to the next run as "before". The first change for each competitor has only the diff to compare against; every one after that has the full plan table.

<Warning>
  A monitor delivers each change once. If your server is down when it posts, that change is not sent again, and the next brief compares against the plans saved before it. Keep the endpoint up, and have it answer before doing any work, as both versions above do.
</Warning>

## What you get back

The agent's answer, as the schema shapes it:

```json theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
{
  "headline": "Northwind moved SSO from Team to Business and raised Business to $49 a seat.",
  "changes": [
    { "plan": "Team", "what": "SSO removed", "before": "SAML SSO included", "after": "Not included" },
    { "plan": "Business", "what": "Monthly price", "before": "$39 per seat", "after": "$49 per seat" },
    { "plan": "Business", "what": "Minimum seats", "before": "None", "after": "10 seats" }
  ],
  "plans": [
    { "name": "Free", "monthly": "$0", "annual": "$0", "limits": ["3 seats", "1 project"] },
    { "name": "Team", "monthly": "$19 per seat", "annual": "$15 per seat", "limits": ["Unlimited projects", "No SSO"] },
    { "name": "Business", "monthly": "$49 per seat", "annual": "$41 per seat", "limits": ["10 seat minimum", "SAML SSO", "Audit log"] }
  ],
  "whatItMeans": "A 10-person team that needs SSO now pays Northwind $490 a month against $590 for our Business plan, down from a $200 gap. A 4-person team that needs SSO now pays Northwind for 10 seats.",
  "suggestedResponse": "Brief sales on small teams that need SSO this week: our Business plan has no seat minimum."
}
```

In Slack it arrives as a header with the headline, one line per change (`• *Business*: Monthly price (was $39 per seat, now $49 per seat)`), the two paragraphs, and a footer linking the page, the replay and the credits the brief cost.

## What it costs

* **Watching** is 1 credit per check, whether or not the page changed, plus the model's usage when a changed page is judged (`judge: true`), per [pricing](/docs/billing/pricing). Three pages checked every 30 minutes is 4,320 checks a month, so **4,320 credits a month**.
* **Each brief** is one agent run, billed for the model's usage, browser time and proxy usage. A typical run costs about 15 credits a minute all in; the real figure depends on the task and how heavy the pages are. About 90 s is **about 23 credits per brief**. Each run's `caps.budget` is 100.
* Six real changes a month across the three competitors: 4,320 + 6 × 23, **about 4,458 credits a month**, plus model usage for the six judged changes. That fits the Builder plan's 10,000.

A change that is only a new banner still starts a brief. Add its selector to `ignoreSelectors` and the monitor stops seeing it.

## Make it yours

* Post straight to a channel with no server: `notify: { slack: '<incoming webhook URL>' }` sends the monitor's own diff and summary to Slack, without the agent's brief.
* Watch the changelog and the docs' limits page as well as the pricing page, with one monitor per URL on the same endpoint.
* Put the brief where your team decides: open a Linear issue, update a Notion table or a HubSpot property with one more HTTP call after the Slack post.
* Watch a pricing page behind a login by creating the monitor and the run with the same `profile`.

## Related

<CardGroup cols={2}>
  <Card title="Monitors" icon="eye" href="/docs/monitors/overview">Schedules, notify targets and normalisation.</Card>
  <Card title="Agents" icon="player-play" href="/docs/agents/overview">Tasks, caps and typed results.</Card>
  <Card title="Rules" icon="shield-check" href="/docs/account/rules">Keep a run on one site.</Card>
  <Card title="Sessions and replay" icon="movie" href="/docs/browsers/sessions">See exactly what the agent read.</Card>
</CardGroup>


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