A meeting-prep agent reads the next 24 hours from a calendar export, pulls context from a notes folder and recent mail subjects, and writes a one-page brief per meeting twenty minutes before it starts, to Telegram or a file. It runs on a ten-minute systemd timer, uses a local model only for three suggested questions, and never writes to the calendar or sends anything.
Part of the Always-on agents series, post 19 of 33.
Of all the jobs the vendors put on stage this year, meeting preparation is the one I would hand to an agent first. The reason is boring and important: it is read-only by nature. A brief that is wrong costs me two minutes of confusion before a call. A calendar entry that is wrong costs me the call. So this rebuild stays entirely on the reading side, like the inbox agent before it.
What Microsoft says Scout does
From Microsoft's own announcement on 2 June 2026: Scout can proactively schedule and coordinate meeting times across time zones, flag important meetings, generate the materials you need to prepare, identify upcoming deliverables and automatically block time on your calendar, and spot risks like stalled decisions. It runs under its own governed identity in your Microsoft 365 tenant, reaches only the resources you approve, and can require human sign-off for sensitive actions. On 25 September it was renamed Autopilot and moved to private preview. The headlines post has the rest.
I am rebuilding the middle of that list and leaving the ends alone. Scheduling across time zones and blocking time are writes to a calendar, and I do not let an agent write to mine. Spotting stalled decisions needs the whole tenant's conversations, which a self-hosted agent does not have and should not want. Flagging meetings and generating preparation materials is a read, and that is the job.
The design
Four decisions, and I would defend each one separately.
- The calendar is a file, not an account. The agent reads an .ics export, which every calendar can produce and many can publish at a private URL. No OAuth, no write scope, nothing to revoke.
- Context comes from two read-only places. A folder of markdown notes, matched by attendee names and company names, and the subjects of recent mail over read-only IMAP, matched the same way. Neither is modified.
- The brief is mostly deterministic. When, who, what the invite says, which notes matched, which mail subjects matched: that is code. The model writes only three suggested questions, labelled as a draft.
- Timing is a window, not an alarm. A timer runs the agent every ten minutes; it writes a brief only for meetings starting within the next twenty, and only once per meeting, because the file already exists the second time.
The agent
meeting_prep.py#!/usr/bin/env python3
"""Meeting-prep agent: one brief per meeting in the next 24 hours, written 20 minutes before it starts.
Inputs, all read-only: an .ics export (CAL_FILE), a notes folder (NOTES_DIR, markdown), and the
subjects of recent mail over IMAP (optional). A local model writes the "questions to ask" section;
everything else is deterministic. Output: briefs/<start>-<slug>.md, and the brief text to stdout or
Telegram. Run it on a timer every 10 minutes; it only writes a brief inside the 20-minute window
and never twice for the same meeting.
Environment: CAL_FILE, NOTES_DIR, OUT_DIR, OLLAMA_URL, OLLAMA_MODEL, optional IMAP_* and TELEGRAM_*,
and NOW (ISO timestamp) to pin the clock for a test run.
"""
import datetime as dt
import email
import imaplib
import json
import os
import re
import urllib.request
from email.header import decode_header, make_header
from pathlib import Path
CAL = Path(os.environ["CAL_FILE"])
NOTES = Path(os.environ.get("NOTES_DIR", "notes"))
OUT = Path(os.environ.get("OUT_DIR", "briefs"))
OLLAMA = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:0.5b")
NOW = dt.datetime.fromisoformat(os.environ["NOW"]) if os.environ.get("NOW") else dt.datetime.now()
WINDOW = dt.timedelta(minutes=20)
def events(text):
"""A deliberately small .ics reader: DTSTART, SUMMARY, DESCRIPTION, ATTENDEE lines per VEVENT."""
out = []
for block in re.findall(r"BEGIN:VEVENT(.*?)END:VEVENT", text.replace("\r\n ", ""), re.S):
fields = dict(re.findall(r"^([A-Z]+)(?:;[^:]*)?:(.*)$", block, re.M))
m = re.search(r"(\d{8}T\d{6})", fields.get("DTSTART", ""))
if not m:
continue
start = dt.datetime.strptime(m.group(1), "%Y%m%dT%H%M%S")
attendees = re.findall(r"^ATTENDEE[^:]*:mailto:(\S+)$", block, re.M)
out.append(dict(start=start, summary=fields.get("SUMMARY", "(no title)").strip(),
description=fields.get("DESCRIPTION", "").strip(), attendees=attendees))
return sorted(out, key=lambda e: e["start"])
def notes_for(words):
hits = []
for p in NOTES.glob("*.md"):
body = p.read_text(errors="replace")
if any(w.lower() in body.lower() for w in words):
hits.append((p.name, body.strip()[:600]))
return hits
def mail_subjects(words):
if not os.environ.get("IMAP_HOST"):
return []
box = imaplib.IMAP4(os.environ["IMAP_HOST"], int(os.environ.get("IMAP_PORT", "143")))
box.login(os.environ["IMAP_USER"], os.environ["IMAP_PASS"])
box.select("INBOX", readonly=True)
_, data = box.uid("search", None, "ALL")
subs = []
for uid in data[0].split()[-50:]:
_, d = box.uid("fetch", uid, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT)])")
msg = email.message_from_bytes(d[0][1])
line = "%s: %s" % (msg.get("From", ""), make_header(decode_header(msg.get("Subject", ""))))
if any(w.lower() in line.lower() for w in words):
subs.append(line)
box.logout()
return subs
def ask(prompt):
req = urllib.request.Request(OLLAMA + "/api/generate", method="POST",
data=json.dumps({"model": MODEL, "prompt": prompt, "stream": False,
"options": {"temperature": 0.2}}).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=180) as r:
return json.load(r)["response"].strip()
def main():
OUT.mkdir(exist_ok=True)
written = 0
for e in events(CAL.read_text()):
lead = e["start"] - NOW
if not (dt.timedelta(0) <= lead <= WINDOW):
continue
slug = re.sub(r"[^a-z0-9]+", "-", e["summary"].lower()).strip("-")
path = OUT / ("%s-%s.md" % (e["start"].strftime("%Y%m%d-%H%M"), slug))
if path.exists():
continue
words = [a.split("@")[0] for a in e["attendees"]] + [a.split("@")[1].split(".")[0] for a in e["attendees"]]
words += [w for w in re.findall(r"[A-Za-z]{4,}", e["summary"])]
notes = notes_for(words)
subs = mail_subjects(words)
context = "\n".join("NOTE %s: %s" % n for n in notes) + "\n" + "\n".join("MAIL " + s for s in subs)
questions = ask("You prepare a founder for a meeting. Meeting: %s. Description: %s. Context:\n%s\n\n"
"Write exactly three short questions she should ask, one per line, no numbering."
% (e["summary"], e["description"], context[:2500]))
brief = ["# %s" % e["summary"], "", "**When:** %s (in %d minutes)" % (e["start"].strftime("%a %d %b %H:%M"), lead.seconds // 60),
"**Who:** %s" % (", ".join(e["attendees"]) or "no attendees listed"), ""]
if e["description"]:
brief += ["**Invite says:** " + e["description"], ""]
brief += ["## From my notes"] + (["- %s: %s" % (n[0], n[1][:200]) for n in notes] or ["- nothing on file"]) + [""]
brief += ["## Recent mail"] + (["- " + s for s in subs] or ["- nothing recent"]) + [""]
brief += ["## Questions to ask (model draft, check before use)"] + ["- " + q.strip("- ").strip() for q in questions.splitlines() if q.strip()][:3]
text = "\n".join(brief)
path.write_text(text)
print(text)
print("\n---\n")
written += 1
print("briefs written: %d (now=%s)" % (written, NOW.strftime("%Y-%m-%d %H:%M")))
if __name__ == "__main__":
main()The .ics reader is deliberately small and handles the fields I need; a real calendar export has time zones, recurrence rules and all-day events, and for those I would reach for a library rather than extend the regular expressions. The point of writing it this way is that the whole thing fits on a screen and I can see exactly what it reads.
A run against sample data
Three events, two notes, six emails in a test mailbox, and the clock pinned to 09:45 so that a 10:00 kickoff fell inside the window while the afternoon contract call and Friday's board update did not. The agent wrote one brief:
Output# Kickoff with Dana (Customer Co)
**When:** Wed 21 Oct 10:00 (in 15 minutes)
**Who:** dana@customer.test, lera@example.test
**Invite says:** Scope the first channel launch and agree the footage handover.
## From my notes
- customer-co.md: # Customer Co (Dana)
Signed in September. Wants Reels first, Shorts later. Footage: 40 hours of webinar recordings on Drive.
Open point: who approves the first week of posts.
## Recent mail
- dana@customer.test: Can we move the kickoff?
## Questions to ask (model draft, check before use)
- 1. Can you confirm if Dana wants to start the first week of posts?
- 2. Are there any specific requirements for the first week of posts?
- 3. Is there anything else you need to discuss before proceeding with the kickoff?
---
briefs written: 1 (now=2026-10-21 09:45)Two things I like and one I do not. The deterministic sections did their job: the brief found the right note by the attendee's domain, and it surfaced the one email that matters, Dana asking to move the very meeting I was about to walk into, which is the kind of thing I would otherwise learn on the call. The window logic held: the afternoon contract call and Friday's board update got no brief. What I do not like is the model's questions. They are generic, the first one is close to wrong, and the 0.5-billion-parameter model ignored my instruction not to number them. That section is labelled a draft for exactly this reason, and on a real setup I would either hand it to a bigger model or drop it and keep the brief purely factual, which is what the code already does without it.
Making it always-on
This one is a polling job rather than a nightly one, so the timer fires every ten minutes and the script decides whether there is anything to do. Persistent=false is deliberate: a brief for a meeting that already happened is noise, so a missed run should not be made up.
systemd units# /etc/systemd/system/meeting-prep.service
[Unit]
Description=Meeting prep briefs (read-only)
[Service]
Type=oneshot
User=agent
WorkingDirectory=/srv/agent
EnvironmentFile=/etc/meeting-prep.env
ExecStart=/srv/agent/venv/bin/python /srv/agent/meeting_prep.py
# /etc/systemd/system/meeting-prep.timer
[Unit]
Description=Check for meetings in the next twenty minutes
[Timer]
OnCalendar=*:0/10
Persistent=false
[Install]
WantedBy=timers.targetTerminalsudo systemd-analyze verify /etc/systemd/system/meeting-prep.service
sudo systemctl enable --now meeting-prep.timer
systemctl list-timers meeting-prep.timerDelivery is the same choice as every job in this series: a Telegram bot token in the environment file and the brief arrives on my phone; without it, a markdown file in a folder I keep open. The Linux core page covers the user, the secrets file and the watchdog; here the watchdog check is simply whether a brief exists for each meeting that has started in the last hour.
Where this stops being honest
The brief is only as good as what is on file. On a sample calendar with two tidy notes it looks excellent. On a real diary, half the meetings have no notes, attendees use personal addresses, and the matching by name finds the wrong Dana. I would run it for a month before trusting the context section, and I would never let it skip the "model draft" label on the questions, because a confident wrong question two minutes before a call is worse than no question. Scout's answer to this is the whole of Microsoft 365 as context. Mine is a smaller agent that tells you what it did not find.
Sources
- Microsoft 365 Blog, Introducing Microsoft Scout: your always-on personal agent (accessed October 20, 2026)
- Python documentation, imaplib: IMAP4 protocol client (accessed October 20, 2026)
- systemd documentation, systemd.timer: OnCalendar and Persistent (accessed October 20, 2026)
Written by Lera Leonteva. The brief shown is from sample data and a pinned clock on the access date, not from my own calendar.