Always-on agents · Same use cases, free tools

A morning brief agent: inbox, calendar, numbers and news at seven, every day

If you build one always-on agent this year, build this one. It cannot send, delete or change anything. It reads four things you already have and writes one message at seven. I built it this afternoon, ran it once in a fresh container, and the brief below is what it wrote.

In short

A morning brief agent reads things you already have, an exported calendar, a list of feeds, a metrics file and an unread count, and writes one message at 07:00. It is read-only by construction, so it is the safest first always-on agent to run unattended. One Python file, no framework, no API key, scheduled by the operating system. The brief below is real output.

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

My own morning brief, today, is two things: a log line written at 09:05 saying what was published, and a watchdog at 09:40 that emails me only when something on the site is broken. Building the second one taught me that silence has to be designed. A job that says nothing can mean all is well or can mean it never ran, and you only find out which when it matters. So when I talk about a morning brief, I mean a message that arrives every day, says the same kinds of things in the same order, and tells you plainly when a source could not be read.

This post builds that. I wrote it as a single Python file with no dependencies beyond the standard library, because the point of a first agent is to understand every line. I ran it once in a fresh python:3.12-slim container with sample data and two real feeds, and the brief it produced is pasted below unedited, including the line where I gave it a feed that does not exist to see what it would say.

What a morning brief agent should and should not do

Mine has four rules. It reads, it never writes anywhere except its own output. It reports counts and headlines, not contents, so an unread count is fine and the text of my email is not. It says when a source failed instead of silently leaving it out. And it runs at the same minute every day whether or not there is anything to say, because the day it does not arrive is the day I learn the machine is asleep.

I am stricter about the inbox than most tutorials. The script can ask an IMAP server for the unread count with a read-only STATUS command, and it does nothing else: no opening, no marking, no moving. If you do not give it a login, it says so in the brief rather than pretending the inbox is empty. That is the honesty rule from the definition post applied to a seven o'clock message.

The four inputs, and why none of them need an API

  • Calendar: an exported .ics file. Google Calendar, Apple Calendar and Outlook all export one, and most will publish a private subscription URL you can fetch the same way. No OAuth, no write scope, nothing the agent can change.
  • Feeds: a text file of RSS or Atom URLs, one per line. The two real ones in my run are the Hacker News front page and Google's news blog. Your competitors, your industry press and your own blog all have one.
  • Numbers: a CSV of date,metric,value rows. Export it from wherever the numbers live, by hand at first. The agent compares yesterday with the day before and gives the percentage change.
  • Inbox: an unread count over IMAP, only if you choose to give it a login, and ideally an app-specific password you can revoke. Mine ran without one.

The script

This is the whole thing. It is about 130 lines, and the three functions that matter are the readers: one parses the calendar export by hand (the format is simple enough), one reads the feeds and keeps the last 24 hours, and one compares two days of metrics. compose() turns them into text, deliver() writes a file and, if you set two environment variables, sends the same text to a Telegram chat.

brief.py#!/usr/bin/env python3
"""Morning brief: read-only inputs in, one message out. It can write exactly one file,
and send exactly one Telegram message to you, and nothing else."""
import csv, datetime as dt, json, os, re, sys, urllib.request
from pathlib import Path
from xml.etree import ElementTree as ET

HERE = Path(__file__).resolve().parent
TODAY = dt.date.today()
TZ = dt.timezone.utc


def read_calendar(path):
    """Today's events from an exported .ics file. No calendar API, no OAuth, no write access."""
    events, cur = [], None
    for raw in Path(path).read_text(encoding="utf-8").splitlines():
        line = raw.strip()
        if line == "BEGIN:VEVENT":
            cur = {}
        elif line == "END:VEVENT" and cur is not None:
            events.append(cur); cur = None
        elif cur is not None and ":" in line:
            key, val = line.split(":", 1)
            cur[key.split(";")[0]] = val
    out = []
    for e in events:
        start = e.get("DTSTART", "")
        if start[:8] == TODAY.strftime("%Y%m%d"):
            hhmm = start[9:11] + ":" + start[11:13] if "T" in start else "all day"
            out.append((hhmm, e.get("SUMMARY", "(no title)")))
    return sorted(out)


def read_feeds(path, since_hours=24):
    """Headlines newer than since_hours from RSS or Atom URLs, one per line."""
    cutoff = dt.datetime.now(TZ) - dt.timedelta(hours=since_hours)
    items = []
    for url in Path(path).read_text().split():
        try:
            req = urllib.request.Request(url, headers={"User-Agent": "morning-brief/1"})
            with urllib.request.urlopen(req, timeout=20) as r:
                root = ET.fromstring(r.read())
        except Exception as exc:
            items.append(("!", url, "could not read feed: %s" % exc.__class__.__name__)); continue
        ns = {"a": "http://www.w3.org/2005/Atom"}
        for it in root.iter("item"):                              # RSS
            when = parse_date(it.findtext("pubDate") or "")
            title = (it.findtext("title") or "").strip()
            if title and (when is None or when >= cutoff):
                items.append((when, root.findtext("channel/title") or url, title))
        for it in root.findall(".//a:entry", ns):                 # Atom
            when = parse_date(it.findtext("a:updated", namespaces=ns) or "")
            title = (it.findtext("a:title", namespaces=ns) or "").strip()
            if title and (when is None or when >= cutoff):
                items.append((when, root.findtext("a:title", namespaces=ns) or url, title))
    return items


def parse_date(s):
    for fmt in ("%a, %d %b %Y %H:%M:%S %z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%SZ"):
        try:
            d = dt.datetime.strptime(s.strip().replace("GMT", "+0000"), fmt)
            return d if d.tzinfo else d.replace(tzinfo=TZ)
        except ValueError:
            continue
    return None


def read_metrics(path):
    """Yesterday against the day before, from a CSV of date,metric,value rows."""
    by_day = {}
    for r in csv.DictReader(open(path, encoding="utf-8")):
        by_day.setdefault(r["date"], {})[r["metric"]] = float(r["value"])
    days = sorted(by_day)
    if len(days) < 2:
        return []
    y, b = by_day[days[-1]], by_day[days[-2]]
    return [(m, y[m], (y[m] - b[m]) / b[m] * 100) for m in sorted(y) if b.get(m)]


def unread_count():
    """Only if you give it an IMAP login: a read-only STATUS call. Nothing is opened, marked or moved.
    A count, not contents: the brief should tell me the inbox is busy, not read my mail."""
    host, user, pw = (os.environ.get(k) for k in ("BRIEF_IMAP_HOST", "BRIEF_IMAP_USER", "BRIEF_IMAP_PASSWORD"))
    if not (host and user and pw):
        return None
    import imaplib
    m = imaplib.IMAP4_SSL(host); m.login(user, pw)
    typ, data = m.status("INBOX", "(UNSEEN)"); m.logout()
    return int(re.search(rb"UNSEEN (\d+)", data[0]).group(1))


def compose():
    lines = ["Morning brief, %s" % TODAY.strftime("%A %-d %B %Y"), ""]
    cal = read_calendar(HERE / "calendar.ics")
    lines.append("Today (%d): " % len(cal) + ("; ".join("%s %s" % c for c in cal) if cal else "nothing booked"))
    unread = unread_count()
    lines.append("Inbox: %s" % ("%d unread" % unread if unread is not None else "not connected (no IMAP login given)"))
    mets = read_metrics(HERE / "metrics.csv")
    if mets:
        lines.append("Numbers (yesterday vs the day before): " + "; ".join("%s %g (%+.0f%%)" % m for m in mets))
    feeds = read_feeds(HERE / "feeds.txt")
    good = [f for f in feeds if f[0] != "!"]
    lines += ["", "Headlines from the last 24h (%d):" % len(good)]
    newest = sorted(good, key=lambda f: f[0] or dt.datetime.min.replace(tzinfo=TZ), reverse=True)
    lines += ["  - %s: %s" % (src, title) for _w, src, title in newest[:8]]
    lines += ["  ! %s (%s)" % (url, why) for _m, url, why in feeds if _m == "!"]
    return "\n".join(lines)


def deliver(text):
    out = HERE / ("brief-%s.txt" % TODAY.isoformat())
    out.write_text(text, encoding="utf-8")
    token, chat = os.environ.get("BRIEF_TELEGRAM_TOKEN"), os.environ.get("BRIEF_TELEGRAM_CHAT")
    if token and chat:
        body = json.dumps({"chat_id": chat, "text": text}).encode()
        req = urllib.request.Request("https://api.telegram.org/bot%s/sendMessage" % token, data=body,
                                     headers={"Content-Type": "application/json"})
        urllib.request.urlopen(req, timeout=20).read()
        return "sent to Telegram and saved to %s" % out.name
    return "saved to %s (set BRIEF_TELEGRAM_TOKEN and BRIEF_TELEGRAM_CHAT to send it)" % out.name


if __name__ == "__main__":
    brief = compose()
    print(brief)
    print("\n[" + deliver(brief) + "]", file=sys.stderr)

Telegram is my suggestion for delivery because a bot token is free, takes a minute to get from BotFather, and the message lands on your phone. The script never reads Telegram, it only posts one message to one chat id. If you would rather not, leave the variables unset and read the file.

What it wrote when I ran it

Sample data: a calendar with two events today and one tomorrow (to check the filter), three feeds of which one is deliberately broken, and six rows of metrics across two days. One command in a fresh container:

Terminaldocker run --rm -v "$PWD:/brief" -w /brief python:3.12-slim python brief.py
Output, 20 October 2026Morning brief, Tuesday 20 October 2026

Today (2): 09:30 Standup with the editing team; 14:00 Demo call, agency prospect
Inbox: not connected (no IMAP login given)
Numbers (yesterday vs the day before): reach 21950 (+21%); reels_posted 7 (+17%); signups 2 (-33%)

Headlines from the last 24h (25):
  - News from Google: Get hands-free personalized help on the road with Gemini
  - Hacker News: Front Page: SimCity, but with Permits
  - Hacker News: Front Page: Claude designed protein binders against scorpion neurotoxins
  - Hacker News: Front Page: What shall I teach my son?
  - Hacker News: Front Page: Google open-sources TorchTPU, a native PyTorch back end for TPUs
  - Hacker News: Front Page: Voxtral 3
  - News from Google: Ask a Scientist: How can AI help doctors spend more time on collaborative care?
  - News from Google: New Gemini tools for small business owners
  ! https://example.invalid/feed.xml (could not read feed: URLError)

[saved to brief-2026-10-20.txt (set BRIEF_TELEGRAM_TOKEN and BRIEF_TELEGRAM_CHAT to send it)]

Three things I check in that output every time I change the script. Tomorrow's event did not leak into today. The broken feed is reported with a reason rather than dropped. And the metric that fell is shown as a negative number, not hidden; a brief that only reports good news is a brief you stop reading. The first run also found a bug of mine, a misplaced bracket in the file name, which is exactly why I run everything once by hand before I schedule it.

Scheduling it for seven o'clock

This is a scheduled job, not a daemon: it runs for a few seconds and exits. On Linux I use a systemd timer rather than cron because it logs to the journal and has a setting that runs a missed job on the next boot, which is the difference between a brief that survives a reboot and one that does not. The pattern is the one from the Linux core page; here is the minimum.

/etc/systemd/system/brief.service[Unit]
Description=Morning brief
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=agent
WorkingDirectory=/opt/brief
EnvironmentFile=-/etc/brief/env
ExecStart=/usr/bin/python3 /opt/brief/brief.py
/etc/systemd/system/brief.timer[Unit]
Description=Morning brief at 07:00

[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=true

[Install]
WantedBy=timers.target
Terminalsudo systemctl daemon-reload
sudo systemctl enable --now brief.timer
systemctl list-timers brief.timer
journalctl -u brief.service -n 20

On a Mac the equivalent is a launchd agent with a StartCalendarInterval of hour 7, minute 0, which is how my own 09:05 publishing job is scheduled; the Mac guide has the full file and the sleep settings that matter more than the schedule. On Windows, Task Scheduler at 07:00 running python brief.py is enough, see the Windows guide. The Telegram token goes in the environment file, readable by the agent user only, never in the script.

Where to take it next

The first week, change nothing and read it. You will find out which sources you actually want at seven and which you skip. Then, in this order: add your own metrics export, add a line that says which of yesterday's scheduled jobs ran (mine would read the publish log), and only then consider a model. A small local model through Ollama can turn 25 headlines into three sentences and it is the natural first judgement step, because the worst it can do is summarise badly. The post on agents versus automation is about exactly where that line sits.

What this agent is not: a reader of your email, a sender of anything, or a thing that acts. That is the point. The next build in this series, the Slack triage loop, is the first one that proposes an action, and it has a human confirmation step for that reason.

Sources

  1. Telegram, Bots: an introduction for developers (accessed October 20, 2026)
  2. freedesktop.org, systemd.timer manual page (accessed October 20, 2026)
  3. IETF, RFC 5545: iCalendar (accessed October 20, 2026)
  4. Python docs, imaplib, IMAP4 protocol client (accessed October 20, 2026)

Written by Lera Leonteva. The script and its output were run once on 20 October 2026 in a fresh python:3.12-slim container on my Mac; the headlines are whatever the two feeds carried that afternoon.

Frequently asked questions

Do I need an API key to run a morning brief agent?

No. The version in this post uses only the Python standard library and reads files and public feeds. The only credential is optional: a Telegram bot token to deliver the message to your phone, or an IMAP login if you want an unread count. Both can be revoked without touching the script.

Why not connect it straight to Google Calendar or Gmail?

Because a first always-on agent should be impossible to misuse. An exported .ics file and an IMAP read-only STATUS call give me what a brief needs without granting any write scope. Once the brief has run reliably for a month, a calendar API with a read-only scope is a reasonable next step.

How do I schedule it to run at 07:00?

On Linux, a systemd timer with OnCalendar set to 07:00 and Persistent=true, so a missed run happens at the next boot. On a Mac, a launchd agent with StartCalendarInterval. On Windows, a Task Scheduler task. In each case the machine has to be awake, which is the real requirement.

Can a local AI model summarise the brief?

Yes, and it is the right first use of a model here, because summarising badly is the worst outcome. Pipe the headlines through a small model via Ollama and keep the raw list underneath so you can check it. I would add that only after a week of reading the plain version.

What should the brief do when a source is down?

Say so, in the message, with a reason. In my run the deliberately broken feed appears as a line starting with an exclamation mark and the error class. A brief that silently drops a failed source trains you to trust an incomplete picture.

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.