DEV Community

Steven
Steven

Posted on

I built a Claude-powered feed monitor in an afternoon. Then I actually ran it.

The pitch is easy: point a script at an RSS feed, have Claude read each item and decide whether it's worth your attention, and get a short digest in Telegram instead of scrolling. You've probably seen a dozen versions of this. There's a prompt floating around that generates most of it for you in one shot.

So I built it. The happy path took about an afternoon: fetch the Hacker News front page, score each item against a plain-language description of what I care about, dedupe against what I've already seen, send a Telegram message. Thirty items in, a handful out, each with a one-line reason it made the cut.

Then I ran it against real data, and it broke three times. Not in dramatic ways. In quiet ways, the kind that pass every test you write in the first hour and then fail the first time reality hands you input you didn't imagine. This post is about those three, because they're the difference between "I got it working on my machine" and "it runs every morning and I trust it."

Here's what a run looks like when it's behaving:

1. Show HN: Open-source engine running Gemma 4 26B in 2 GB RAM on any M-series Mac
This is developer tooling for running LLMs locally, an open-source engine optimizing inference on consumer hardware.
https://github.com/...

2. Kimi K3-256k
A model release announcement, developer-usable tooling for building with LLMs.
https://www.kimi.com/...

Five of those, pulled from thirty. Now the three ways it got there.

Bug 1: the message that crashes on a punctuation mark

The first version formatted the Telegram message with Markdown, because Markdown gives you bold titles and clean links. Telegram's Bot API supports it directly. You set parse_mode and away you go.

# what I wrote first
def send_telegram(token, chat_id, text):
    resp = requests.post(
        TELEGRAM_API.format(token=token),
        json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown"},
        timeout=15,
    )
    resp.raise_for_status()
Enter fullscreen mode Exit fullscreen mode

This works perfectly until a title contains a character Markdown treats as syntax. Telegram's legacy Markdown parser expects those characters to be balanced. An underscore has to close another underscore. An open bracket needs its partner. When they don't match, the parser doesn't degrade gracefully or strip the character. It rejects the entire message with a 400 Bad Request: can't parse entities and sends nothing.

Now think about what lives on the Hacker News front page. Titles like some_function() considered harmful, or Building [x] with [y], or anything with an asterisk or a stray backtick. Unbalanced Markdown entities are not an edge case in developer headlines. They're most days.

The insidious part is the timing. The crash happens at the very end, after the feed was fetched, after every item was scored (which costs real API calls), after all the work is done. You pay for the whole run and get nothing, because one title three items down had an underscore in it.

The fix isn't to escape the characters. You can, but then you're maintaining an escaping function against a parser whose rules you don't control, and it'll bite you again the first time Telegram changes them. The fix is to stop asking the parser to interpret arbitrary text as syntax at all:

# what actually ships
def send_telegram(token, chat_id, text):
    # Plain text, no parse_mode. Item titles are arbitrary source text
    # and Telegram's Markdown parser errors on unmatched _ * ` [ in them.
    resp = requests.post(
        TELEGRAM_API.format(token=token),
        json={"chat_id": chat_id, "text": text},
        timeout=15,
    )
    resp.raise_for_status()
Enter fullscreen mode Exit fullscreen mode

Plain text can't crash on content, because there's no syntax to mis-parse. You lose bold titles. You gain a message that sends every single time regardless of what a stranger put in their headline. For something that runs unattended every morning, that trade is not close.

(That's the fix for this bug in isolation. This same function comes back in bug 3, because sending plain text solved one crash and set up the next one.)

Bug 2: the scorer that silently said no to everything

This is the one that scared me, because it didn't crash. It just quietly lied.

The scoring step asks Claude, per item, whether it matches the criteria and why. I told it to respond as JSON so I could parse the result cleanly:

text = response.content[0].text.strip()
try:
    result = json.loads(text)
except json.JSONDecodeError:
    return {"match": False, "reason": "unparseable model response"}
return {"match": bool(result.get("match")), "reason": result.get("reason", "")}
Enter fullscreen mode Exit fullscreen mode

Look at that code for a second, because it looks careful. It even has error handling: if the response won't parse, it returns a clean "no match" with a reason. That felt responsible. It was the whole problem.

I ran it. Zero matches. I figured the front page was just a slow day, loosened the criteria, ran again. Zero matches. Loosened them almost to "anything technical." Still zero.

The criteria weren't the problem. The parser was. Claude, asked for JSON, was wrapping its response in a Markdown code fence — three backticks, the word "json", the actual JSON object, then three closing backticks.

The parser call sees the backticks and the word json before the actual object and throws a JSONDecodeError. And my careful little except caught that error and returned match: False. So every single item was scored, correctly, by Claude, and then thrown away at the parsing step because the response had three backticks in front of it. The bot dutifully reported "no matches today," every time, with total confidence.

This is worse than a crash. A crash tells you something is wrong. A silent default tells you nothing and lets you burn an afternoon adjusting the wrong variable. I only caught it because "zero matches no matter how loose the criteria" is statistically absurd, and that smell finally made me print the raw response instead of the parsed one.

The fix is to strip the fence before parsing:

text = response.content[0].text.strip()
# Strip leading Markdown code fence if the model wrapped its response
FENCE = chr(96) * 3  # three backticks
if text.startswith(FENCE):
    text = text.strip(chr(96))
    text = text.removeprefix("json").strip()
try:
    result = json.loads(text)
except json.JSONDecodeError:
    return {"match": False, "reason": "unparseable model response"}
return {"match": bool(result.get("match")), "reason": result.get("reason", "")}
Enter fullscreen mode Exit fullscreen mode

The except stays, because a genuinely malformed response should still fail safe. But notice what changed underneath it: the fence-stripping runs first, so the common case (Claude wrapping valid JSON in a code block) parses cleanly instead of getting swallowed. The lesson isn't "strip the fence." It's that a quiet default in an except is where bugs go to hide. My error handling wasn't protecting me from a rare failure, it was silently absorbing the normal case and reporting it as an empty result. If that except had logged even once, I'd have found this in thirty seconds instead of an hour.

Bug 3: the digest that was too good

Once the scorer actually worked, I had the opposite problem. A run matched sixteen items, assembled them into one nice digest, and tried to send it. 400 Bad Request again, different reason.

Telegram caps a single message at 4096 characters. My sixteen-item digest was 4744. Over the limit, rejected, nothing sent. And because the send failed before the "mark these as seen" step ran, the state didn't persist either, so the next run would try to send the same oversized message and fail again. A quiet little infinite failure loop.

You can't just truncate at 4096, because you'd cut a message off mid-item, mid-link, mid-sentence. The fix is to split into multiple messages, but only ever on the boundary between items. The digest already puts a blank line between entries, so splitting on double newlines guarantees every chunk is a clean set of whole items:

def chunk_text(text, limit=TELEGRAM_MAX_LEN):
    """Split on item boundaries (blank lines) so no item is cut mid-way."""
    if len(text) <= limit:
        return [text]
    chunks = []
    current = ""
    for part in text.split("\n\n"):
        if current:
            candidate = current + "\n\n" + part
        else:
            candidate = part
        if len(candidate) > limit and current:
            chunks.append(current)
            current = part
        else:
            current = candidate
    if current:
        chunks.append(current)
    return chunks
Enter fullscreen mode Exit fullscreen mode

The final send_telegram then loops over the chunks and sends each one as a separate plain-text message: same body as the send_telegram from Bug 1, wrapped in a for chunk in chunk_text(text) loop that calls requests.post per chunk.

Now a heavy day sends two or three messages instead of crashing, and each one is a coherent list you can actually read. The function ends up carrying both fixes at once: plain text so content can't crash the parser, chunked so length can't crash the send.

What these three have in common

None of them showed up in the afternoon build. All three showed up in the first few real runs. And all three are invisible until they aren't:

  • The Markdown crash needs a specific character in a specific title.
  • The JSON-fence bug needs you to notice that "zero matches" is a lie, not a result.
  • The length crash needs a day with enough matches to cross a line you didn't know was there.

That's the actual gap between a script that works when you run it and a tool you leave running. The demo is an afternoon. The three things that make it survive contact with real feeds are a second afternoon of hitting walls you can't predict from the outside, which is exactly why they're easy to leave out of the tutorial version and exactly why they matter.

The code

The worked example is public. Fetch a feed, score with Claude, dedupe, send a digest, and the three fixes above are all in it. Clone it, add your own API key and a Telegram bot token, point it at whatever feed you care about, and change the scoring criteria to plain English describing what you want to see:

https://github.com/stevenclawd-pixel/monitor-kit

It runs on Claude Haiku, so scoring a feed once a day costs a few cents a month.

If you'd rather not wire up the deploy plumbing yourself, dedupe, restart-safe scheduling, retries, the config pattern for adding your own sources without touching code, I'm building that into a full kit and taking pre-orders for it here: https://monitorkit.netlify.app. But the demo above stands on its own, and the three bugs are yours to avoid whether you buy anything or not.

If you've built something similar, I'm curious which of these three you hit first. My money's on the JSON fence.

Top comments (0)