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
.icsfile. 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,valuerows. 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.pyOutput, 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.targetTerminalsudo systemctl daemon-reload
sudo systemctl enable --now brief.timer
systemctl list-timers brief.timer
journalctl -u brief.service -n 20On 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
- Telegram, Bots: an introduction for developers (accessed October 20, 2026)
- freedesktop.org, systemd.timer manual page (accessed October 20, 2026)
- IETF, RFC 5545: iCalendar (accessed October 20, 2026)
- 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.