A Brand Monitoring Agent That Knows What's Worth Waking You For

Polling news for your company name is easy. Deciding which of the forty hits actually matters is the part that needs an agent.

Profile picture of Serply
Serply
A news feed being triaged into urgent and routine buckets

Brand monitoring tools mostly fail in the same direction: they alert on everything, so you stop reading the alerts. The volume problem isn’t a retrieval problem. It’s a judgment problem, which is exactly what a model is for.

Pulling coverage

Serply’s News endpoint returns a parsed feed. The shape differs from the web search endpoint in a way that trips people up — articles live under feed.entries, not results:

import os
import requests
from urllib.parse import quote_plus

API_KEY = os.environ["SERPLY_API_KEY"]


def news_search(query: str) -> list[dict]:
    resp = requests.get(
        f"https://api.serply.io/v1/news/q={quote_plus(query)}",
        headers={"X-Api-Key": API_KEY},
        timeout=30,
    )
    resp.raise_for_status()
    return resp.json().get("feed", {}).get("entries", [])


entries = news_search("Acme Corp")
for e in entries[:5]:
    print(e.get("published"), "|", e.get("source"), "|", e.get("title"))

Each entry carries title, link, summary, published, and source, plus a few parsed variants (published_parsed, title_detail) you can usually ignore. The response also includes a top-level entities array.

Query construction

A bare company name is the wrong query for anything with a common word in it. Build a small set of variants and merge:

def brand_queries(brand: str, aliases: list[str], domain: str) -> list[str]:
    qs = [f'"{brand}"'] + [f'"{a}"' for a in aliases]
    qs.append(f'"{brand}" (lawsuit OR investigation OR recall OR breach)')
    qs.append(f"site:{domain} OR \"{brand}\" announcement")
    return qs


def gather(brand: str, aliases: list[str], domain: str) -> list[dict]:
    seen, out = set(), []
    for q in brand_queries(brand, aliases, domain):
        for e in news_search(q):
            link = e.get("link")
            if link and link not in seen:
                seen.add(link)
                out.append(e)
    return out

The risk-term query is worth its own call. Those stories are a small fraction of total coverage and the ones you most need to not miss, and they’re easily buried when you merge everything by recency.

Triage

Summaries in a news feed are short — often a sentence. That’s enough for triage but not for a decision, so triage first and read second.

import json
from anthropic import Anthropic

client = Anthropic()

TRIAGE = """You triage news coverage for a company's comms team.

For each article, assign:
- severity: "critical" | "notable" | "routine" | "irrelevant"
- reason: one sentence

critical = legal action, safety, security breach, executive departure, or coverage
  that alleges wrongdoing
notable = product news, funding, partnerships, meaningful analyst commentary
routine = listings, roundups, syndicated reprints, passing mentions
irrelevant = different company with a similar name, or the brand term used generically

Return a JSON array, one object per article, in the same order. No prose."""


def triage(entries: list[dict]) -> list[dict]:
    listing = "\n\n".join(
        f"{i}. {e.get('title')}\nSource: {e.get('source')} ({e.get('published')})\n{e.get('summary', '')[:400]}"
        for i, e in enumerate(entries)
    )
    msg = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=4000,
        system=TRIAGE,
        messages=[{"role": "user", "content": listing}],
    )
    verdicts = json.loads(msg.content[0].text)
    return [{**e, **v} for e, v in zip(entries, verdicts)]

Batching all articles into one call rather than one call per article is the difference between a workable cost profile and an unworkable one. It also gives the model comparative context — “routine” is a judgment relative to the rest of today’s coverage.

The irrelevant category matters more than it looks. Any brand with a dictionary word in the name pulls in a steady stream of false positives, and a triage scheme without an explicit bucket for them will file them as routine and pollute your metrics.

Reading the critical ones

def read_article(url: str) -> str | None:
    try:
        resp = requests.post(
            "https://api.serply.io/v1/request",
            headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
            json={"url": url, "response_type": "markdown"},
            timeout=60,
        )
        resp.raise_for_status()
        return resp.text[:12000]
    except requests.RequestException:
        return None

Markdown mode returns the text as the response body, so .text is correct here. News sites are among the worst offenders for markup bloat — a two-paragraph story can be 400KB of HTML — and the markdown conversion is doing real work on your token bill.

The escalation brief

BRIEF = """Write a 4-sentence brief for a comms lead who has not read the article.
State what happened, who is making the claim, what is verifiably true versus alleged,
and what the immediate exposure is. No recommendations. No hedging language."""


def brief(article: dict, full_text: str) -> str:
    msg = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=600,
        system=BRIEF,
        messages=[{"role": "user", "content":
                   f"{article['title']}\n{article['link']}\n\n{full_text}"}],
    )
    return msg.content[0].text

Separating “what is verifiably true” from “what is alleged” is the single most useful instruction in that prompt. Coverage of an accusation and coverage of a finding read almost identically in a headline and require entirely different responses.

Scheduling

Run it on a cron, keep a store of link values you’ve already triaged, and only alert on new criticals. Watch for 429 under a tight polling schedule — the response headers include x-ratelimit-requests-remaining, so you can widen the interval before you start dropping cycles rather than after.