Always-on agents · Same use cases, free tools

A meeting-prep agent that briefs you before every call

Microsoft's first always-on agent, Scout, now Autopilot, sells itself partly on meetings: it flags the important ones and generates what you need to prepare. I like that job for an agent, because the input is a calendar and the output is a page I can ignore. Here is the version I built with free software, run once against sample data so you can see what a brief looks like before you let anything near your diary.

In short

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 was testedI ran the script below in a Python container on 20 October 2026 against a sample calendar export with three events, two notes files and a test mailbox, with the clock pinned so one meeting fell inside the twenty-minute window. The brief shown is the real output. I have not run it against my own calendar yet, and the Telegram delivery is described but was not exercised.

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.target
Terminalsudo systemd-analyze verify /etc/systemd/system/meeting-prep.service
sudo systemctl enable --now meeting-prep.timer
systemctl list-timers meeting-prep.timer

Delivery 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

  1. Microsoft 365 Blog, Introducing Microsoft Scout: your always-on personal agent (accessed October 20, 2026)
  2. Python documentation, imaplib: IMAP4 protocol client (accessed October 20, 2026)
  3. 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.

Frequently asked questions

How does the agent read my calendar without access to my account?

From an .ics export, which every calendar application can produce and most can publish at a private URL. The agent only reads the file. There is no OAuth grant and no write scope to revoke.

Does it write to the calendar or send anything?

No. It reads the calendar file, a notes folder and mail subjects over read-only IMAP, and writes a markdown brief to a folder or sends it to a Telegram chat. Scheduling and blocking time, which Microsoft's Scout does, are writes I deliberately left out.

Why does it run every ten minutes instead of once in the morning?

So the brief lands twenty minutes before the meeting, when I will read it, and so it includes mail that arrived during the day. A morning run is a different job; the morning brief post in this series covers it.

What does the local model do here?

Only the three suggested questions, labelled as a draft. Everything else in the brief is assembled by the code from the calendar, the notes and the mail subjects, so a small model is enough and a wrong answer is contained to one section.

Which parts were tested?

The script ran in a container against a three-event calendar, two notes and a seeded test mailbox, with the clock pinned so one meeting fell in the window. The brief in the post is that run's output. Telegram delivery and a real calendar were not exercised.

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.