Skip to content

Lowly Worm โ€” newsfeed ๐Ÿ›๐Ÿ“ฐ

Last updated: 2026-04-13 ยท Reading time: ~20 min ยท Difficulty: moderate

TL;DR

  • Lowly Worm is the filter between me and a morning of doom-scrolling. I still read the news โ€” he just makes sure I'm not spending forty minutes before coffee foraging across six news sites looking for anything worth reading. Every morning at 5am PT he fetches WSJ, NYT, WaPo, Slate, and Google News, dedupes across outlets, ranks against a preference model that learns from my reactions, and delivers a personal morning edition to Telegram with inline ๐Ÿ‘/๐Ÿ‘Ž/๐Ÿ“– buttons on each item.
  • Preference learning is the hero of this chapter. A coarse topic-weight model tunes itself from your thumbs-up/down signals, and a fine subtopic model gets proposed by an LLM judge on every thumbs-down. You don't have to know the subtopic taxonomy up front โ€” the judge writes it for you as you react, and the digest gets sharper over time without any manual intervention. This is what makes a personalized feed actually useful instead of just "okay."
  • Lowly Worm is deliberately isolated from the shared brain. He's the only agent in the fleet without read or write access to ~/Dropbox/openclaw-backup/. His prompt-injection attack surface is large (he reads the open web); confining him to his own workspace keeps any injected instruction from reaching the rest of the fleet.
  • Cron surface is two LLM crons in the manifest (morning-edition, preference-update) plus two host crons (engagement polling, morning delivery) that moved off the LLM dispatch queue because they don't need judgment. Engagement logging in particular is 100% deterministic โ€” the poller reads session transcripts directly and the LLM never touches it.
  • If you also want Lowly to watch a social channel (LinkedIn, X, Bluesky, etc.) for filtered notifications and consolidated DMs, that's an optional extension covered in Ch 07-2b. The core newsfeed stands on its own and is useful without any of that. Deploy this chapter first; add 07-2b only if you care about the social half.

Meet the agent

Lowly Worm is the smallest citizen of Busytown and โ€” for reasons that never quite made sense โ€” also the one you saw most often. The original Lowly rides around in a hollowed-out apple, shows up in every scene of every book, and seems to know a little about everything happening in town. Curious. Well-read. Endlessly involved in whatever was interesting at the moment. This Lowly got a narrower assignment: he is the filter between me and a morning of scrolling. I still read the news. What he does is fetch it from five or six different places, filter it against my preferences, and hand me a scannable digest before I've had coffee. He has editorial opinions about signal vs. noise. He notes when a story is getting disproportionate coverage. He admits it honestly when a query turns up nothing interesting. He does not ride in an apple car. Here's what's happening.

Why you'd want one โ€” and why you might not

Lowly Worm is the filter between me and a morning of doom-scrolling. I read the news; I just refuse to do the foraging. Without an agent, my first hour of the day used to involve five news tabs, and the algorithm on every one of them was optimizing for engagement rather than for what I actually needed to know. With Lowly, a single Telegram thread arrives at 5am PT, grouped by topic, with a one-sentence context gloss on each item and inline buttons to react. I can scan it in ninety seconds and read the three or four things that matter. He costs effectively nothing โ€” every LLM call he makes rides on the flat-rate OpenAI subscription I'm already paying for, via openclaw infer โ€” and the preference model sharpens over time. After a few weeks of thumbs-up/down signals the digest is noticeably more about what I read and less about what's loudly trending. For a reader who wants a daily briefing without the dopamine economy of a news site's landing page, he is the most obvious win in the fleet.

Why you might not. Three cases where even the core Lowly is a poor fit. First: if you're already disciplined about reading the news directly and don't want an algorithmic filter shaping what you see, you don't need a middleman โ€” skip him. Second: if your threat model puts prompt injection high on the list, remember that Lowly deliberately reads untrusted content from the open web on a schedule. The isolation from the shared brain mitigates that, but doesn't eliminate it. Third: if what you actually want is the social-channel half (filtered LinkedIn notifications, DM consolidation) and you don't care about RSS newsfeeds at all, jump to Ch 07-2b โ€” though even then the core Lowly deployed here is still the plumbing that the extension sits on top of.

What success looks like

A curated, personalized daily news feed that lives in a single Telegram thread, costs effectively nothing beyond your existing LLM subscription, and gets sharper over time.

Concretely, the morning edition is:

  • Ranked and deduplicated against WSJ, NYT, WaPo, Slate, and Google News (plus whatever else you drop into the feed list in fetch-and-rank.py).
  • Grouped by topic category โ€” ๐Ÿค– AI & Tech, ๐Ÿ’ฐ Economics, ๐ŸŒ World, ๐Ÿ›๏ธ US Policy, ๐Ÿ“‹ Also Noted, and so on. Emoji category headers make the thread scannable at a glance.
  • Capped at 15-20 items total. The preference model decides what makes the cut, and below-the-fold items get dropped rather than padded.
  • Accompanied by inline ๐Ÿ‘ / ๐Ÿ‘Ž / ๐Ÿ“– buttons on each item โ€” tap ๐Ÿ‘ or ๐Ÿ‘Ž to train the preference model, tap ๐Ÿ“– to get a one-paragraph expansion of that specific article.

The morning edition is not trying to be comprehensive. It's trying to be the five-minute version of what you would have spent forty minutes finding on your own.

Making your newsfeed actually useful โ€” the preference learning story

A morning digest from a static topic list is okay. A morning digest from a list that learns what you care about, with no manual tuning, is genuinely useful. This section is about how the second thing works, and why it's the hero of the chapter rather than an afterthought.

The basic idea: coarse preferences get hand-tuned; fine preferences get learned from your feedback via a cheap LLM judge.

The coarse model

The seed in manifest.json has a topic vocabulary โ€” about ten broad categories (AI, economics, international politics, US domestic policy, tech, markets, science, business, regulation, startups), each with ~15 keyword terms. When fetch-and-rank.py pulls an article, it scores the article against the vocabulary and assigns a topic (or marks it other). The ranking formula then uses topic_weight ร— source_weight ร— recency ร— diversity_bonus.

Topic and source weights start at 1.0 and shift from there. Thumbs-up on an item: topic weight ร—1.1, source weight ร—1.05. Thumbs-down: topic ร—0.85, source ร—0.95. Clamp to [0.1, 5.0] to prevent runaway values. Over a few weeks of thumbing, the weights settle into a shape that matches what you actually read.

If you stopped here, you'd have a decent personalized feed โ€” but it would only learn at the topic level. Thumbs-down on "AI safety" can't distinguish "AI safety from a researcher I trust" from "AI safety from a Twitter pundit" โ€” both are "AI," both get the same weight change. That's the limit of a coarse-only model, and it's why the fine model below is load-bearing rather than optional.

The fine model, via an LLM judge

The interesting half is what happens on a thumbs-down. update-preferences.py runs nightly, reads the day's engagement.jsonl, and for every thumbs-down it invokes a small openclaw infer call:

Here's the article title, headline, and source. The reader marked this as irrelevant. In one sentence, why? Then output a subtopic tag from this list, or propose a new one.

The judge's answer gets written to a secondary weight table โ€” not at topic level, but at subtopic level. So "AI safety (researcher)" and "AI safety (pundit)" become separate keys with their own weights, and the next day's ranking uses both. Over time the subtopic weights get richer than the topic weights, and the digest gets noticeably sharper.

The magic is that you don't have to know the subtopic taxonomy up front. The judge proposes new tags as it sees new kinds of thumbs-downs, and the table grows organically. After a few weeks I had subtopic tags like "AI-safety-from-pundit", "markets-daily-noise", "celebrity-tech-gossip", and "thought-leader-post-without-substance" โ€” each of which got filtered down over time. None of those were in my original vocabulary; the judge wrote them all.

Cost: effectively zero on top of your existing LLM subscription. openclaw infer routes through whichever LLM provider you configured in Ch 02. In my case that is a flat-rate ChatGPT Plus / Codex subscription โ€” no per-token billing, no per-call cost, just a small slice of the subscription I'm already paying for. The judge fires per thumbs-down event, there are usually 2-5 of them a day, and each call is a tiny structured-output request. If you're on a pay-per-token API key instead of a subscription, the math changes from "free" to "pennies per day."

The preference model lives in preferences/model.json and is portable โ€” reset it by deleting the file and deploy.py re-seeds from the manifest.

This coarse-hand-tuned + fine-LLM-judge-learned pattern is not specifically an OpenClaw pattern. It's an approach to personalized ranking that generalizes to any feed you want to rank against a preference model. I'm documenting it here in the main body of the chapter โ€” not in an appendix โ€” because it is the non-obvious part of what makes Lowly Worm feel genuinely valuable in practice. Without it, the morning edition is a generic RSS digest. With it, it's a personal newspaper that gets more personal every week.

Deployment walkthrough

The general 8-step arc from Ch 07-0 applies. The Lowly-specific variations:

The bot and the token

Lowly's Telegram bot is @openclaw_newsdigest_bot. Create it via BotFather per the usual flow, and save the token as NEWSDIGEST_BOT_TOKEN=โ€ฆ in your local .env. Unlike Mr Fixit (who binds to the default Telegram account), Lowly binds to the named telegram:newsdigest account โ€” he only sends, he doesn't need to receive free-text messages, and the callback_query updates from inline button taps come in via a side channel that the named account handles fine.

The 2-cron manifest + 2 host crons

Lowly's manifest.json declares only two LLM crons, because most of his work is deterministic and doesn't need the LLM:

Cron Schedule What it does
morning-edition 30 10 * * * Runs fetch-and-rank.py, which fetches all configured RSS feeds and ranks everything against preferences/model.json. The LLM selects the top 15-20 items, writes a one-sentence extended headline for each, groups them by topic category, and writes cache/morning-items.json (structured) + cache/morning-brief-ready.txt (plain-text fallback). Does not deliver โ€” the host cron picks up the file.
preference-update 0 23 * * * Runs update-preferences.py, which reads the day's engagement signals from preferences/engagement.jsonl, invokes openclaw infer once per thumbs-down for the LLM judge, and updates both the topic-level and subtopic-level weights in preferences/model.json. Silent on no-op days.

Plus two host crons installed by ops/scripts/install-host-cron.sh (see Ch 07-1 for the host-cron install pattern):

Host cron Schedule What it does
news-digest-engagement-poll */5 * * * * Runs engagement-poller.py, which reads the agent's session transcript files directly, extracts /like N / /dislike N / /more N signals (plus inline-keyboard callback_query events), and appends them to preferences/engagement.jsonl. No LLM involvement.
morning-fleet-deliver 0 12 * * * The fleet-wide morning delivery cron from Ch 07-1. Reads cache/morning-items.json, chunks it into per-item Telegram messages with inline ๐Ÿ‘/๐Ÿ‘Ž/๐Ÿ“– buttons, and posts them via NEWSDIGEST_BOT_TOKEN.

If you also deploy Ch 07-2b later, you add a third host cron (linkedin-keepalive) and some extra openclaw infer calls inside fetch-and-rank.py for DM summarization. Those are described there โ€” the core two-cron shape above is what you deploy first.

The compose/deliver split

The shape of the morning edition is worth understanding as a general pattern: the LLM composes, the host delivers. morning-edition (LLM) fetches, ranks, selects, and writes a structured items file. morning-fleet-deliver (host Python) reads that file and posts the messages. They are two separate processes, they communicate through a file on disk, and they can fail independently. If the LLM cron times out mid-compose, morning-items.json is either complete from a previous day (reader sees yesterday's digest โ€” annoying but not broken) or missing (reader sees no digest and Mr Fixit's morning-status alerts). If the host delivery cron crashes, the items file is still on disk and you can re-deliver manually. Neither side has to know the other side's concerns.

This split is also why morning-edition fires at 30 10 * * * and morning-fleet-deliver at 0 12 * * * โ€” 90 minutes of slack. Lowly's ranking script regularly takes 3-8 minutes, and the first iteration had the two crons only 10 minutes apart, which meant a slow ranking run occasionally missed the delivery window. Ninety minutes is wildly generous, but it's free โ€” nothing else is waiting on the file.

Smoke test

After deploy.py news-digest, run the morning-edition cron manually to verify the whole compose chain works:

oc cron list | grep news-digest
oc cron run <morning-edition-uuid>
# Wait ~5 minutes for the cron to finish
cat ~/.openclaw/news-digest-workspace/cache/morning-items.json | python3 -m json.tool | head

You should see a JSON array of 15-20 items, each with num, category, extended_headline, title, url, source_label, and topics. If morning-items.json doesn't exist or is empty, the LLM cron either failed or didn't understand its instructions โ€” check ~/.openclaw/logs/ and the cron's session transcript.

Pitfalls you'll hit

๐Ÿงจ Pitfall. /like 1 with a space is not tappable on Telegram. Why: Telegram auto-detects slash commands and renders /like as a blue clickable link with 1 as a separate argument. When the user taps the link, Telegram sends only /like to the bot โ€” the argument is stripped. Per-item feedback breaks silently. I hit this at 5am on 2026-04-13 โ€” tapped /like on an item, Lowly replied "I got the like, but I need the item number," and I stared at it for a full minute before realizing the number was never sent. How to avoid: use inline keyboard buttons with callback_data (the preferred UX โ€” {"text": "๐Ÿ‘ like", "callback_data": "like:2"}), or fall back to underscore commands (/like_1) when you can't attach a reply_markup. Both are parsed by engagement-poller.py. Never ship /like N with a space โ€” it looks clickable and isn't.

๐Ÿงจ Pitfall. Asking the LLM to log engagement signals to a file. Why: LLM agents cannot reliably write files in response to short reactive messages like /like 3. I tried four approaches (SOUL instructions, exec scripts, Telegram polling, OpenClaw hooks) and all four failed for different reasons โ€” sometimes the write raced with the cron output, sometimes the agent forgot, sometimes the exec policy prompted for approval on a one-character filename. How to avoid: bypass the LLM entirely. engagement-poller.py runs as a host cron every 5 minutes, reads the agent's OpenClaw session transcripts from /home/node/.openclaw/agents/news-digest/sessions/*.jsonl (which contain every inbound message and callback_query update), and extracts engagement signals deterministically via regex. The LLM never sees this code path. This is a reusable pattern for any reactive feedback an LLM can't reliably handle.

๐Ÿงจ Pitfall. Same article reposted across five outlets; the digest ships five copies. Why: exact-URL deduplication doesn't catch "OpenAI announces X" as reported by WSJ and NYT and Reuters with slightly different canonical URLs. I shipped a morning edition with the same story three times before I noticed. How to avoid: dedupe by URL and by Jaccard similarity on normalized title trigrams, threshold around 0.6. Plus cross-day dedup via cache/sent-history.json (article IDs + normalized titles + timestamps, auto-prune after 3 days) so you don't ship the same story two mornings in a row because it's still trending. fetch-and-rank.py handles both.

๐Ÿงจ Pitfall. The preference model goes runaway in one direction because nothing in a starved topic ever shows up again. Why: a streak of thumbs-downs on one topic drives its weight toward zero, and then the ranking formula produces a score low enough that nothing in that topic ever lands above the cut line, so nothing in that topic ever gets thumbs-up'd either. The topic is effectively dead and won't recover on its own. How to avoid: update-preferences.py clamps all weights to [0.1, 5.0] โ€” that floor matters. If you rewrite the weight-update math, preserve the clamp. A separate safeguard: the diversity bonus in the ranking formula forces at least one item from each major topic into the morning edition regardless of weight, so a starved topic still gets a weekly audition. If you disable that, a bad run of thumbs-downs can quietly kill a category forever.

See also