Short answer: A daily AI agent doesn't need a framework. Write a script that fetches your sources, drops what it has already sent, caps the input, summarizes, delivers, and exits. Then let a systemd timer run it every morning on a small VPS. Below is the fetch-and-dedupe half, tested, and the timer files that schedule it.
Disclosure: the Hostinger and AICredits links are affiliate links. I ran the fetch, 24-hour filter, dedupe and failure handling on 23 Oct 2026 using a fake feed and a stubbed network call. The model call, delivery step and systemd files are unrun on a real server, and the summary prompt is an untested template.
The point of a digest agent is boring reliability. You want it to run at the same time every day, never send duplicates, and never wake you with a bill. Most of the work is that plumbing, not the model call.
What does the script do?
"""Daily digest: fetch RSS feeds, keep items from the last 24h, summarise, send."""
import json
import os
import sys
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
FEEDS = os.environ.get("FEEDS", "").split()
SEEN = Path(os.environ.get("SEEN_FILE", "seen.json"))
MAX_ITEMS = 15 # hard cap on what goes into one prompt
def fetch(url: str) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": "digest-bot/1.0"})
with urllib.request.urlopen(req, timeout=20) as r:
return r.read()
def items(xml_bytes: bytes):
root = ET.fromstring(xml_bytes)
for it in root.iter("item"):
yield {
"title": (it.findtext("title") or "").strip(),
"link": (it.findtext("link") or "").strip(),
"date": parsedate_to_datetime(it.findtext("pubDate")) if it.findtext("pubDate") else None,
}
def collect(feeds, now=None):
now = now or datetime.now(timezone.utc)
seen = set(json.loads(SEEN.read_text())) if SEEN.exists() else set()
fresh = []
for url in feeds:
try:
for it in items(fetch(url)):
if it["link"] in seen or not it["date"]:
continue
if now - it["date"] <= timedelta(hours=24):
fresh.append(it)
except Exception as e: # one bad feed must not kill the run
print(f"feed failed: {url}: {e}", file=sys.stderr)
fresh.sort(key=lambda i: i["date"], reverse=True)
return fresh[:MAX_ITEMS]
def remember(batch):
seen = set(json.loads(SEEN.read_text())) if SEEN.exists() else set()
seen.update(i["link"] for i in batch)
SEEN.write_text(json.dumps(sorted(seen)[-2000:]))
if __name__ == "__main__":
batch = collect(FEEDS)
print(f"{len(batch)} new items")
for i in batch:
print("-", i["title"], i["link"])
# Next: send `batch` to your model for a short summary, then deliver it
# (Telegram, email). Call remember(batch) only after delivery succeeds.
It uses only the standard library, so there's nothing to install for this half. RSS 2.0 feeds only; Atom feeds use different tags and would need handling.
Does the dedupe actually work?
I tested with a fake feed containing three items: one 2 hours old, one 50 hours old and one 10 hours old, with fetch replaced by a function that returns it.
first run: ['New today', 'Also today']
second run: []
feed failed: https://feed.example/rss: network down
bad feed: []
The first run keeps the two items inside 24 hours and drops the 50-hour-old one. After remember(batch), the second run returns nothing, so there are no duplicates. A feed that raises an error is logged to stderr and skipped, so one dead source doesn't kill the whole digest. I didn't test malformed XML beyond the generic exception path, or feeds that lack pubDate (those are skipped on purpose).
The detail that bites in practice: call remember(batch) after delivery, never before. If the send fails and you've already marked the links as seen, tomorrow's digest will never include them.
What do you send to the model?
An untested template. Keep it short and give it only titles and links, so it can't invent content from articles it never read:
You write a morning digest. Below are today's new items as "title - link".
Group them into at most 4 themes. For each theme write one sentence
and list the links. Use only the titles given; do not guess what the
articles say. If an item looks like an advertisement, skip it.
Because it summarizes titles only, the digest is a triage list, not a reading replacement. If you want real summaries, fetch the article text, but that multiplies input tokens and breaks more often. Cap it either way, as in token budgeting for agents.
For delivery, a Telegram message is the least effort: see the Telegram bot tutorial.
How do you schedule it with a systemd timer?
Two files. Replace paths and user (unrun):
# /etc/systemd/system/digest.service
[Unit]
Description=Daily AI digest
[Service]
Type=oneshot
User=digest
WorkingDirectory=/opt/digest
EnvironmentFile=/opt/digest/.env
ExecStart=/usr/bin/python3 /opt/digest/digest.py
# /etc/systemd/system/digest.timer
[Unit]
Description=Run the digest every morning
[Timer]
OnCalendar=*-*-* 07:30:00
Persistent=true
[Install]
WantedBy=timers.target
Enable and check:
sudo systemctl daemon-reload
sudo systemctl enable --now digest.timer
systemctl list-timers digest.timer
journalctl -u digest.service -n 50
OnCalendar uses the server's time zone, so set it to yours first with timedatectl set-timezone Asia/Kolkata or you'll get UTC mornings. Persistent=true makes systemd run a missed job after the machine was down, which is the main reason to prefer a timer over a bare cron line. The systemd vs Docker comparison covers when each fits.
Keep secrets in /opt/digest/.env with chmod 600, owned by the digest user, not in the unit file.
What does it cost to run?
A once-a-day script uses almost no CPU, so the server cost is the same whether it runs this or a dozen other things. The model call is the variable cost, bounded by the 15-item cap and a short output. Use a dedicated key with a small rupee budget on AICredits so a bug that loops can't spend more than the cap. If you'd rather pay a provider directly, a zero-forex card works, as covered in the credit card guide.
If the VPS will hold other always-on tools too, KVM 2 is a comfortable base (see sizing). A digest on its own would run on the smallest plan, or on a scheduler you already have if you don't need a server for anything else. Back up the seen.json file if losing it would send you a flood of old items, and read the hardening checklist before you leave the box exposed.



