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

# Turn website changes into a useful digest

> Monitor a reviewed watchlist, deduplicate signed events, and build a source-linked digest that also exposes failed checks.

export const AgentSetupPrompt = ({children, variant = "setup"}) => {
  const isRecipe = variant === "recipe";
  const promptLabel = isRecipe ? "recipe prompt" : "setup prompt";
  const [copyState, setCopyState] = useState("idle");
  const contentRef = useRef(null);
  const detailsRef = useRef(null);
  const timerRef = useRef(null);
  const mountedRef = useRef(true);
  const pendingRef = useRef(false);
  useEffect(() => {
    mountedRef.current = true;
    return () => {
      mountedRef.current = false;
      if (timerRef.current !== null) clearTimeout(timerRef.current);
    };
  }, []);
  const copyPrompt = useCallback(async () => {
    if (pendingRef.current) return;
    pendingRef.current = true;
    if (timerRef.current !== null) {
      clearTimeout(timerRef.current);
      timerRef.current = null;
    }
    setCopyState("copying");
    try {
      const code = contentRef.current?.querySelector("pre code") ?? contentRef.current?.querySelector("pre");
      const prompt = code?.textContent;
      if (typeof prompt !== "string" || !prompt.trim()) {
        throw new Error("Prompt is not available.");
      }
      if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
        throw new Error("Clipboard access is not available.");
      }
      await navigator.clipboard.writeText(prompt);
      if (!mountedRef.current) return;
      setCopyState("copied");
      timerRef.current = setTimeout(() => {
        if (mountedRef.current) setCopyState("idle");
        timerRef.current = null;
      }, 4000);
    } catch {
      if (!mountedRef.current) return;
      if (detailsRef.current) detailsRef.current.open = true;
      setCopyState("error");
    } finally {
      pendingRef.current = false;
    }
  }, []);
  const feedback = copyState === "copied" ? `${isRecipe ? "Recipe" : "Setup"} prompt copied. Paste it into your coding agent.` : copyState === "error" ? "Couldn't copy automatically. The prompt is open below. Select and copy the text manually." : "";
  return <section className="agent-setup-prompt not-prose" aria-label={`Agent ${promptLabel}`}>
      <style>{`
        .agent-setup-prompt {
          --asp-surface: oklch(0.985 0.005 255);
          --asp-border: oklch(0.9 0.012 255);
          --asp-title: oklch(0.25 0.025 255);
          --asp-text: oklch(0.45 0.021 255);
          --asp-accent: oklch(0.49 0.2 264);
          --asp-accent-hover: oklch(0.44 0.19 264);
          --asp-on-accent: oklch(0.985 0.005 255);
          --asp-focus: oklch(0.6 0.19 255);
          margin: 1.5rem 0;
          border: 1px solid var(--asp-border);
          border-radius: 0.875rem;
          background: var(--asp-surface);
          color: var(--asp-title);
          font-family: inherit;
          min-width: 0;
        }
        .dark .agent-setup-prompt {
          --asp-surface: oklch(0.2 0.014 255);
          --asp-border: oklch(0.33 0.016 255);
          --asp-title: oklch(0.96 0.006 255);
          --asp-text: oklch(0.78 0.013 255);
          --asp-accent: oklch(0.73 0.15 255);
          --asp-accent-hover: oklch(0.8 0.11 255);
          --asp-on-accent: oklch(0.19 0.025 255);
          --asp-focus: oklch(0.78 0.14 255);
        }
        .agent-setup-prompt__header { padding: 1.125rem 1.25rem; }
        .agent-setup-prompt__row {
          display: flex;
          align-items: flex-start;
          justify-content: space-between;
          gap: 1.25rem;
        }
        .agent-setup-prompt__intro { min-width: 0; }
        .agent-setup-prompt__title {
          margin: 0;
          font-size: 1.125rem;
          font-weight: 650;
          line-height: 1.4;
          color: var(--asp-title);
        }
        .agent-setup-prompt__subtitle {
          max-width: 65ch;
          margin: 0.375rem 0 0;
          font-size: 0.875rem;
          line-height: 1.6;
          color: var(--asp-text);
        }
        .agent-setup-prompt__copy {
          display: inline-flex;
          flex-shrink: 0;
          align-items: center;
          justify-content: center;
          gap: 0.5rem;
          min-height: 2.75rem;
          padding: 0.625rem 0.875rem;
          border: 1px solid transparent;
          border-radius: 0.5rem;
          background: var(--asp-accent);
          color: var(--asp-on-accent);
          font: inherit;
          font-size: 0.8125rem;
          font-weight: 600;
          line-height: 1.4;
          cursor: pointer;
        }
        .agent-setup-prompt__copy:hover:not(:disabled) {
          background: var(--asp-accent-hover);
        }
        .agent-setup-prompt__copy:disabled { cursor: wait; }
        .agent-setup-prompt__copy:focus-visible,
        .agent-setup-prompt__summary:focus-visible {
          outline: 2px solid var(--asp-focus);
          outline-offset: 3px;
        }
        .agent-setup-prompt__feedback {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
          line-height: 1.5;
          color: var(--asp-text);
        }
        .agent-setup-prompt__feedback:empty { margin: 0; }
        .agent-setup-prompt__details { border-top: 1px solid var(--asp-border); }
        .agent-setup-prompt__summary {
          display: flex;
          align-items: center;
          gap: 0.5rem;
          padding: 0.75rem 1.25rem;
          border-radius: 0 0 0.875rem 0.875rem;
          font-size: 0.8125rem;
          font-weight: 500;
          line-height: 1.5;
          color: var(--asp-text);
          list-style: none;
          cursor: pointer;
        }
        .agent-setup-prompt__summary::-webkit-details-marker { display: none; }
        .agent-setup-prompt__summary:hover { color: var(--asp-title); }
        .agent-setup-prompt__details[open] .agent-setup-prompt__chevron {
          transform: rotate(90deg);
        }
        .agent-setup-prompt__content { min-width: 0; padding: 0 1.25rem 1rem; }
        .agent-setup-prompt__content > :first-child { margin-top: 0; }
        .agent-setup-prompt__content > :last-child { margin-bottom: 0; }
        .agent-setup-prompt__content pre {
          max-height: 32rem;
          overflow: auto;
          white-space: pre-wrap;
          overflow-wrap: anywhere;
        }
        @media (max-width: 640px) {
          .agent-setup-prompt__header { padding: 1rem; }
          .agent-setup-prompt__row { flex-direction: column; gap: 0.875rem; }
          .agent-setup-prompt__copy { width: 100%; }
          .agent-setup-prompt__summary { padding: 0.75rem 1rem; }
          .agent-setup-prompt__content { padding: 0 1rem 1rem; }
        }
      `}</style>

      <div className="agent-setup-prompt__header">
        <div className="agent-setup-prompt__row">
          <div className="agent-setup-prompt__intro">
            <h2 className="agent-setup-prompt__title">
              {isRecipe ? "Have your agent implement this recipe" : "Set up Context.dev"}
            </h2>
            <p className="agent-setup-prompt__subtitle">
              {isRecipe ? "Copy this prompt into your coding agent to build the recipe in your project." : "Paste into your coding agent. It will ask about your goal and project before making changes."}
            </p>
          </div>
          <button className="agent-setup-prompt__copy" type="button" onClick={copyPrompt} disabled={copyState === "copying"} aria-busy={copyState === "copying"}>
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true" focusable="false">
              {copyState === "copied" ? <path d="m5 12 4 4L19 6" strokeLinecap="round" strokeLinejoin="round" /> : <>
                  <rect x="8" y="8" width="12" height="12" rx="2" />
                  <path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3" />
                </>}
            </svg>
            {copyState === "copied" ? "Copied" : copyState === "copying" ? "Copying…" : `Copy ${promptLabel}`}
          </button>
        </div>
        <p className="agent-setup-prompt__feedback" role="status" aria-live="polite" aria-atomic="true">
          {feedback}
        </p>
      </div>

      <details className="agent-setup-prompt__details" ref={detailsRef}>
        <summary className="agent-setup-prompt__summary">
          <svg className="agent-setup-prompt__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true" focusable="false">
            <path d="m9 5 7 7-7 7" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
          {`View ${promptLabel}`}
        </summary>
        <div className="agent-setup-prompt__content" ref={contentRef}>
          {children}
        </div>
      </details>
    </section>;
};

<AgentSetupPrompt variant="recipe">
  ```text Recipe prompt theme={null}
  Implement this recipe in my project:
  https://docs.context.dev/use-cases/website-change-digests

  Read the recipe and linked API guides, inspect this project's stack, and build the complete flow using its existing conventions.

  Build a reviewed watchlist with meaningful monitor targets and supported schedules. Store monitor IDs and secrets, verify webhook signatures against raw request bytes, and save events durably before processing. Deduplicate event and change IDs, build source-linked digests, and reconcile missed deliveries through paginated runs and changes. Show failed checks separately from successful checks with no changes.

  Reuse existing Context.dev configuration and keep secret API keys on the server. Add focused tests, run the relevant checks, and document setup and how to try the result.
  ```
</AgentSetupPrompt>

Track relevant changes to careers, pricing, product, or company pages and turn them into a digest. Context.dev detects changes; your application saves events, decides what belongs in the digest, and handles notification delivery.

[Adapt uses Context.dev to monitor websites](https://www.context.dev/blog/adapt-puts-20k-accounts-and-6000-websites-to-work-with-context-dev). This recipe connects Monitors to a durable event store, with polling to recover missed deliveries and expose failed checks.

## Define what matters on each source

Create a watchlist with an application ID, source URL, owner, and description of the relevant change. Choose the target by the question you want to answer:

| Question                                  | Target and detection                             |
| ----------------------------------------- | ------------------------------------------------ |
| Did any visible text change?              | `page` with `exact`                              |
| Did this page announce a relevant change? | `page` with `semantic` and specific instructions |
| Were matching URLs added or removed?      | `sitemap` with `exact`                           |
| Did a structured fact change?             | `extract` with `semantic`                        |

Use the [monitoring guide](/guides/monitor-website-changes#create-a-monitor) for cURL and SDK requests. For example, send this body to `POST /monitors`, replacing the source and webhook URL:

```json Monitor request body theme={null}
{
  "name": "Example careers updates",
  "target": {
    "type": "page",
    "url": "https://example.com/careers",
    "instructions": "Report newly listed or removed engineering jobs. Ignore navigation, copyright years, and unrelated copy edits."
  },
  "change_detection": { "type": "semantic", "confidence_threshold": 0.8 },
  "schedule": { "type": "interval", "frequency": 6, "unit": "hours" },
  "webhook": {
    "url": "https://app.example.com/hooks/context/watch-123",
    "events": ["change.detected", "run.completed"]
  }
}
```

The interval must be at least 10 minutes. Save the returned monitor `id`, `initial_run_id`, and generated webhook secret with your watchlist record. Store the secret securely. The first run establishes a baseline; it is not a new-change alert.

## Verify and persist before acknowledging

Read the raw webhook body and verify `X-Context-Signature` using the [signature verification implementation](/guides/monitor-website-changes#verify-every-webhook). The signature covers the timestamp and raw bytes. This Python equivalent uses the same protocol:

```python verify_webhook.py theme={null}
import hashlib
import hmac
import re
import time

def verify_webhook(raw_body, signature_header, secret, now=None):
    parts = {}
    for part in signature_header.split(","):
        key, separator, value = part.strip().partition("=")
        if separator:
            if key in parts:
                return False
            parts[key] = value
    timestamp = parts.get("t", "")
    signature = parts.get("v1", "")
    if not re.fullmatch(r"[0-9]{1,12}", timestamp) or not re.fullmatch(r"[0-9a-fA-F]{64}", signature):
        return False
    now = time.time() if now is None else now
    if abs(now - int(timestamp)) > 300:
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature.lower())
```

Reject a false verification result before parsing or storing the event. Compute the signature from the original request bytes.

The following Python store starts after that verification step. `accept_verified_event` also checks that the event belongs to the monitor associated with the verified secret. Route your webhook using the application watch ID, resolve its stored monitor and secret, then verify the signature, validate the payload, and call this function.

```python digest_store.py theme={null}
import json
import sqlite3

def open_digest(path):
    db = sqlite3.connect(path)
    db.row_factory = sqlite3.Row
    db.executescript("""
      CREATE TABLE IF NOT EXISTS events (
        id TEXT PRIMARY KEY, monitor_id TEXT NOT NULL, payload_json TEXT NOT NULL,
        processed INTEGER NOT NULL DEFAULT 0
      );
      CREATE TABLE IF NOT EXISTS changes (
        id TEXT PRIMARY KEY, monitor_id TEXT NOT NULL, title TEXT NOT NULL,
        summary TEXT NOT NULL, source_url TEXT NOT NULL, detected_at TEXT NOT NULL,
        payload_json TEXT NOT NULL
      );
      CREATE TABLE IF NOT EXISTS runs (
        id TEXT PRIMARY KEY, monitor_id TEXT NOT NULL, status TEXT NOT NULL,
        started_at TEXT, completed_at TEXT, error_json TEXT
      );
    """)
    return db

def accept_verified_event(db, event, expected_monitor_id):
    if event.get("event") not in ("change.detected", "run.completed"):
        raise ValueError("Unsupported event")
    if not isinstance(event.get("id"), str) or not event["id"]:
        raise ValueError("Missing event ID")
    data = event["data"]
    primary = data["change"] if event["event"] == "change.detected" else data["run"]
    if primary["monitor_id"] != expected_monitor_id:
        raise ValueError("Event belongs to another monitor")
    if data.get("change") and data["change"]["monitor_id"] != expected_monitor_id:
        raise ValueError("Change belongs to another monitor")
    with db:
        db.execute("INSERT INTO events (id, monitor_id, payload_json) VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING",
            (event["id"], expected_monitor_id, json.dumps(event)))

def save_change(db, change):
    existing = db.execute("SELECT payload_json FROM changes WHERE id=?", (change["id"],)).fetchone()
    payload = {**json.loads(existing["payload_json"]), **change} if existing else change
    db.execute("""
      INSERT INTO changes VALUES (?, ?, ?, ?, ?, ?, ?)
      ON CONFLICT(id) DO UPDATE SET payload_json=excluded.payload_json
    """,
        (change["id"], change["monitor_id"], change["title"], change["summary"],
         change["url"], change["detected_at"], json.dumps(payload)))

def save_run(db, run):
    db.execute("""
      INSERT INTO runs VALUES (?, ?, ?, ?, ?, ?)
      ON CONFLICT(id) DO UPDATE SET status=excluded.status,
        started_at=excluded.started_at, completed_at=excluded.completed_at,
        error_json=excluded.error_json
      WHERE runs.status NOT IN ('completed', 'failed', 'skipped')
         OR excluded.status IN ('completed', 'failed', 'skipped')
    """, (run["id"], run["monitor_id"], run["status"], run.get("started_at"),
          run.get("completed_at"), json.dumps(run.get("error"))))

def process_events(db):
    for row in db.execute("SELECT * FROM events WHERE processed=0").fetchall():
        event = json.loads(row["payload_json"])
        with db:
            if event["event"] == "run.completed":
                save_run(db, event["data"]["run"])
            if event["data"].get("change"):
                save_change(db, event["data"]["change"])
            db.execute("UPDATE events SET processed=1 WHERE id=?", (row["id"],))

def digest_items(db, start, end):
    # UTC timestamps in a consistent ISO 8601 format; [start, end).
    return [dict(row) for row in db.execute("""
      SELECT id, monitor_id, title, summary, source_url, detected_at
      FROM changes WHERE detected_at >= ? AND detected_at < ?
      ORDER BY detected_at, id
    """, (start, end))]
```

Validate parsed events against the [`change.detected`](/api-reference/monitors/webhook-payload) or [`run.completed`](/api-reference/monitors/webhook-run-completed) schema before calling the store. Return `2xx` only after the event insert commits. A worker can then call `process_events`; its transaction saves the derived records and marks the event processed together.

Deduplicate both levels. Event `id` handles a repeated delivery. Change `id` handles a changed run arriving through both event subscriptions, or later through polling. Both paths should produce one digest item.

## Reconcile deliveries and failed checks

`run.completed` reports completed runs, including baselines and runs with no change. Failed and skipped checks require the [runs API](/api-reference/monitors/runs); absence of a webhook does not mean “no change.”

The following polling worker imports the store functions above. It reads every page of runs and changes and uses the same record IDs as the webhook path. For a larger history, persist a reconciliation cursor and continue the job across bounded worker executions.

```python reconcile_digest.py theme={null}
import json
import os
from urllib.parse import urlencode, quote
from urllib.request import Request, urlopen
from digest_store import save_change, save_run

def api_pages(path):
    cursor = None
    seen = set()
    while True:
        params = {"limit": 100}
        if cursor:
            params["cursor"] = cursor
        request = Request(
            f"https://api.context.dev/v1{path}?{urlencode(params)}",
            headers={"Authorization": f"Bearer {os.environ['CONTEXT_DEV_API_KEY']}"},
        )
        with urlopen(request, timeout=30) as response:
            page = json.load(response)
        yield page["data"]
        if not page["has_more"]:
            break
        cursor = page["next_cursor"]
        if not cursor or cursor in seen:
            raise ValueError("Invalid pagination cursor")
        seen.add(cursor)

def reconcile(db, monitor_id):
    encoded_id = quote(monitor_id, safe="")
    for page in api_pages(f"/monitors/{encoded_id}/runs"):
        with db:
            for run in page:
                save_run(db, run)
    for page in api_pages(f"/monitors/{encoded_id}/changes"):
        with db:
            for change in page:
                save_change(db, change)
```

The changes list returns summaries. Fetch the [full change](/api-reference/monitors/change) by its ID if the digest needs a diff or before-and-after evidence that was not saved from a webhook. A later fetch should fill missing detail on the existing change, not create another item.

Only advance your reconciliation checkpoint after every page is saved. If a request fails, keep already saved rows, leave the job incomplete, and resume with a bounded retry policy. Inspect the monitor's `webhook_failure` and individual run delivery records when diagnosing delivery problems.

## Make freshness visible in the digest

Compute the last successful observation from completed runs rather than from the latest attempt:

```sql Last successful check theme={null}
SELECT monitor_id, MAX(completed_at) AS last_successful_check
FROM runs
WHERE status = 'completed'
GROUP BY monitor_id;
```

Show the latest failed or skipped check alongside this value. A baseline is a successful observation, but it has not yet compared two snapshots. Keep that distinction visible for newly added sources.

Each digest item should link to the observed URL and include its detection time. For sitemap changes, a newly discovered URL is a candidate for follow-up extraction; its appearance alone does not prove that a new product or job exists. Keep the original change evidence available behind summaries.

Build delivery around a saved digest edition and its change IDs. Use an outbox or equivalent durable queue, and record provider delivery state so worker retries do not repeatedly send the same edition. Notification channels and summarization belong to your application.

## Exercise duplicate and missing events

Process one change event twice, then process the `run.completed` event for the same changed run: the store should contain one change. Skip another webhook and reconcile it through the API. Add a failed run and check that the last successful observation remains intact while the failure appears in the watchlist.

<CardGroup cols={2}>
  <Card title="Monitor setup and signatures" icon="bell" href="/guides/monitor-website-changes">
    Configure targets, intervals, and verified webhook delivery.
  </Card>

  <Card title="Structured datasets" icon="database" href="/use-cases/structured-web-datasets">
    Refresh extracted records after a relevant source change.
  </Card>
</CardGroup>
