Always-on agents · Same use cases, free tools

A competitor and brand watch agent that reads the feeds every hour

Leo exists because watching what an audience responds to is a job no person can do every hour of every day. This is that job, stripped to its listening half and built with nothing but Python, public feeds and a timer. I ran it this afternoon against three real feeds; the output is below, unedited.

In short

A watch agent reads public sources on a schedule, remembers everything it has already seen, summarises what is new, and flags the items that match the words you care about. Mine runs hourly from a systemd timer, stores state in SQLite, uses a local model for the summary, and delivers to Telegram. It is the listening half of Leo, not the acting half.

Part of the Always-on agents series, post 22 of 33.

People sometimes ask me what Leo is, in one line, and the honest one-liner is: an agent that listens to what a brand's audience is responding to, then acts on it with the brand's own footage. The acting half is the hard part and the part I sell. The listening half is something anyone can build in an afternoon, so I did, and I want to show you exactly what it does and does not get you.

What a watch agent is, and what it is not

By the three tests in my definition post, a watch agent is the simplest thing that still qualifies. It holds a goal across runs, because it remembers what it has seen. It starts itself, from a timer. And it acts by telling you what changed. Where it sits on the line between automation and agent, which I drew in the agent versus automation post, depends on one step: whether a model reads the new items and decides what matters, or whether a word list does. I built it so you can run it either way, and I will be clear about which way the run below used.

It is not social listening in the sense the tools mean. It reads public feeds: RSS, Atom, YouTube channel feeds, news search feeds. It does not log into any platform, scrape behind a login, or watch engagement numbers. That is deliberate. Everything in the series runs under the rules from the permissions post, and the first rule is that nothing unattended holds a login it does not need.

The loop

  1. Read every feed on the list. RSS and Atom, which covers news sites, blogs, YouTube channels (every channel has an Atom feed at youtube.com/feeds/videos.xml?channel_id=...), GitHub releases and Hacker News searches.
  2. Skip anything already seen. Every entry id goes into a SQLite table the first time. The table is the agent's memory and the reason the second run is quiet.
  3. Flag what matches the watch words. Competitor names, your brand, 'pricing', 'outage', 'launch'. This step is a word list and it is meant to be: it must work even when the model is down.
  4. Summarise what is new. A local model turns twenty new titles into three lines and a list of what needs a human. Skipped on the first run, when everything is new and a summary would be noise.
  5. Deliver. Print to the log; if a Telegram bot token is set, send the brief there too. The absence of a message means nothing changed, which is a signal I had to learn to trust.

The code

One file, standard library only. It talks to an Ollama server for the summary if you pass one; otherwise it prints the raw list and carries on.

watch.py"""A competitor and brand watch agent: public feeds in, what changed out, a model summarises,
a human gets the flagged items. Runs once per invocation; a systemd timer runs it hourly.

Usage: python3 watch.py feeds.txt state.sqlite [ollama_host] [model]
Delivery: prints the brief; with TELEGRAM_TOKEN and TELEGRAM_CHAT set it also sends it.
"""
import json, os, sqlite3, sys, urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timezone

feeds_file, db_path = sys.argv[1], sys.argv[2]
OLLAMA = sys.argv[3] if len(sys.argv) > 3 else os.environ.get("OLLAMA", "")
MODEL = sys.argv[4] if len(sys.argv) > 4 else "qwen2.5:1.5b"
WATCH = [w.strip().lower() for w in os.environ.get("WATCH_WORDS", "agent,launch,pricing,outage").split(",")]

db = sqlite3.connect(db_path)
db.execute("CREATE TABLE IF NOT EXISTS seen (id TEXT PRIMARY KEY, feed TEXT, title TEXT, link TEXT, first_seen TEXT)")

def fetch(url):
    req = urllib.request.Request(url, headers={"User-Agent": "ao-watch/0.1"})
    with urllib.request.urlopen(req, timeout=30) as r:
        return r.read()

def entries(xml):
    root = ET.fromstring(xml)
    ns = {"a": "http://www.w3.org/2005/Atom"}
    out = []
    for it in root.iter("item"):                                   # RSS
        out.append((it.findtext("guid") or it.findtext("link"), it.findtext("title") or "", it.findtext("link") or ""))
    for e in root.iter("{http://www.w3.org/2005/Atom}entry"):      # Atom, which YouTube uses
        link = e.find("a:link", ns)
        out.append((e.findtext("a:id", namespaces=ns), e.findtext("a:title", default="", namespaces=ns),
                    link.get("href") if link is not None else ""))
    return [(i, t.strip(), l) for i, t, l in out if i]

def summarise(items):
    if not OLLAMA:
        return "(no model configured; raw list below)"
    prompt = ("You are a brand and competitor watch. Summarise these new items in three short lines, "
              "then list which ones need a human to look (anything about pricing, outages, launches, or "
              "mentions of the watched words: %s). Items:\n%s"
              % (", ".join(WATCH), "\n".join("- %s (%s)" % (t, f) for f, t, _ in items)))
    body = {"model": MODEL, "stream": False, "options": {"temperature": 0},
            "messages": [{"role": "user", "content": prompt}]}
    req = urllib.request.Request(OLLAMA + "/api/chat", data=json.dumps(body).encode(),
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=600) as r:
        return json.load(r)["message"]["content"].strip()

new = []
now = datetime.now(timezone.utc).isoformat(timespec="minutes")
for line in open(feeds_file):
    url = line.strip()
    if not url or url.startswith("#"):
        continue
    try:
        xml = fetch(url)
    except Exception as e:
        print("FEED FAILED %s: %s" % (url, str(e)[:80]))
        continue
    for eid, title, link in entries(xml):
        if db.execute("SELECT 1 FROM seen WHERE id=?", (eid,)).fetchone():
            continue
        db.execute("INSERT INTO seen VALUES (?,?,?,?,?)", (eid, url, title, link, now))
        new.append((url.split("/")[2], title, link))
db.commit()

flagged = [(f, t, l) for f, t, l in new if any(w in t.lower() for w in WATCH)]
first_run = db.execute("SELECT COUNT(DISTINCT first_seen) FROM seen").fetchone()[0] == 1
brief = ["Watch brief %s: %d new item(s) across %d feed(s)%s" % (
    now, len(new), len(set(f for f, _, _ in new)), " (first run: everything is new)" if first_run else "")]
if new and not first_run:
    brief.append(summarise(new[:20]))
if flagged:
    brief.append("Needs a human (%d):" % len(flagged))
    brief += ["  * [%s] %s %s" % (f, t, l) for f, t, l in flagged[:10]]
text = "\n".join(brief)
print(text)

tok, chat = os.environ.get("TELEGRAM_TOKEN"), os.environ.get("TELEGRAM_CHAT")
if tok and chat:
    data = json.dumps({"chat_id": chat, "text": text[:4000]}).encode()
    urllib.request.urlopen(urllib.request.Request(
        "https://api.telegram.org/bot%s/sendMessage" % tok, data=data,
        headers={"Content-Type": "application/json"}), timeout=30)
    print("sent to Telegram")

The feed list for this run: a technology news site, a Hacker News search for the phrase this series is about, and a YouTube channel.

feeds.txthttps://www.theverge.com/rss/index.xml
https://hnrss.org/newest?q=always-on+agent
https://www.youtube.com/feeds/videos.xml?channel_id=UC_x5XG1OV2P6uZZ5FSM9Ttw

What it produced

I ran it twice in a fresh python:3.12-slim container on my Mac on 20 October 2026, three minutes apart, with no model attached, so the summary step printed its fallback line. This is the output as it came out, with one dash in a quoted title changed to a hyphen for our house style and nothing else touched:

First runWatch brief 2026-10-20T20:15+00:00: 28 new item(s) across 3 feed(s) (first run: everything is new)
Needs a human (3):
  * [hnrss.org] Always-On Agent Computers for Supabase https://sanbox.cloud/blog/always-on-sanbox-computers-for-supabase/
  * [hnrss.org] Heartbeat - open implementation of KAIROS, the always-on agent hiden in Claude C https://github.com/uameer/heartbeat
  * [hnrss.org] The Always-On Agent Economy and the Next Cloud Supercycle https://clouddataandai.substack.com/p/the-always-on-agent-economy-and-the
Second run, three minutes laterWatch brief 2026-10-20T20:18+00:00: 1 new item(s) across 1 feed(s)
(no model configured; raw list below)

The first run saw 28 items and flagged three, all from the Hacker News search, all because the title contained the word agent, which was one of the watch words. The second run saw one new item and flagged nothing. That is the whole value of the memory table: an hourly job that reports only the delta is something you will actually read. An hourly job that re-reports 28 items is something you will mute by Thursday.

The three flagged items are also a fair picture of what a word list does. Two are genuinely about always-on agents. One is a blog post whose headline happens to use the phrase. A model in the summary step would have told them apart; in the morning brief post I show the same pattern with a model attached, and in the local models post, which goes up tomorrow, I measure how well the small ones do at exactly this sorting job.

Running it every hour

The unit and timer are the ones from the Linux core guide, with one line changed. I think the timer matters more than the service here: a watch agent that runs hourly and misses a run while the machine is off should catch up on the next boot, not silently skip the gap, and Persistent=true is what gives you that. My own publishing job learned that lesson the hard way on a Monday in September.

watch.timer[Unit]
Description=Competitor and brand watch, hourly

[Timer]
OnCalendar=hourly
Persistent=true
RandomizedDelaySec=3m

[Install]
WantedBy=timers.target
watch.service[Unit]
Description=One watch run
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=agent
WorkingDirectory=/srv/agent
EnvironmentFile=/srv/agent/agent.env
ExecStart=/srv/agent/venv/bin/python /srv/agent/watch.py /srv/agent/feeds.txt /srv/agent/state.sqlite http://127.0.0.1:11434 qwen2.5:1.5b

The Telegram token and chat id live in the environment file, readable only by the agent's user, never in the unit and never in the script.

What Leo does that this does not

I want to be exact here, because I sell the other half. This agent tells you what competitors and publications said. Leo watches what an audience is actually responding to, hour by hour, matches that against footage a brand already has, edits a clip to fit, and books it onto a calendar. If nothing fits, it posts nothing. The listening in Leo is about response, not mentions, and the acting is the part that takes a team of editors out of the loop. If the listening half is all you need, build this. If you want the channel to run itself, that is the product.

Sources

  1. Google, YouTube channel Atom feeds (videos.xml) (accessed October 20, 2026)
  2. hnrss, Hacker News RSS search feeds (accessed October 20, 2026)
  3. Telegram, Bot API: sendMessage (accessed October 20, 2026)

Written by Lera Leonteva. Both runs shown were made on 20 October 2026 in a fresh container; feed contents change by the hour.

Frequently asked questions

Is this the same as a social listening tool?

No. Social listening tools log into platforms and watch engagement and mentions at scale. This agent reads public feeds only: RSS, Atom, YouTube channel feeds, news and Hacker News searches. It needs no login, which is exactly why I am comfortable running it unattended.

Why does the first run not summarise?

Because on the first run everything is new, and a model summary of 28 unrelated items is noise. From the second run on, the agent only sees the delta, and a summary of the delta is worth reading.

Can it run without a model?

Yes, and the run shown in this post did. The watch-word flagging is a plain word list so it works when the model is down. The model only adds the three-line summary and a better judgement of what needs a human.

How is this different from Leo?

This is the listening half: what was said. Leo watches what an audience responds to, matches it to footage a brand already has, edits a clip and books it on the calendar, and posts nothing when nothing fits. The acting half is the product.

How do I add a competitor?

Add their blog or news feed and their YouTube channel feed to feeds.txt, and add their name to the watch words. Restart nothing; the next hourly run picks up the new lines.

Lera Leonteva
Lera Leonteva
CEO & Founder, Leo AI

Lera Leonteva is the founder and CEO of Leo AI, where she works on turning long-form footage into short-form video that earns attention on its own merits. She writes Video Scaling Strategies, taking one piece of content that worked and one that did not, and using what the market is doing to explain the gap.

The always-on agent I run for social video

Leo watches what your audience is responding to, picks a clip from footage you already have, edits it and books it on the calendar. Every hour, without being asked. It is the agent this whole series grew out of.

Keep going with the series

Every post in the Always-on agents series is on one page, newest first. If you would rather see the agent I run for social video, here it is.