Always-on agents · Same use cases, free tools

An inbox and calendar agent that works while your laptop is closed

Google's whole pitch for Spark is one sentence from Sundar Pichai: you do not need to keep your laptop open to make sure it is running. I agree with the sentence and disagree with handing my inbox to it. So here is the same idea on a machine I own: an agent that reads my mail, sorts it, proposes calendar entries, and tells me once a night. It cannot send, mark, move or book anything, and I built it that way on purpose.

In short

A self-hosted inbox agent opens the mailbox over IMAP read-only, asks a local model to sort each unread message into needs-reply, meeting, newsletter or ignore, writes a tentative .ics file for anything that proposes a time, and sends one summary a night to Telegram. It runs on a systemd timer with a small Ollama model, costs nothing in API fees, and never sends or books.

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

This is the second of the rebuilds in this series, where I take a use case the big vendors put on stage and build the version I would actually run, with free software, on a machine that stays on. The first was the morning brief. This one is the inbox, which is the use case I am most careful about, because an inbox is where the permissions question stops being abstract.

What was testedI ran the agent below in a Python container on 20 and 21 October 2026 against a test mail server seeded with six sample messages, with 0.5-billion and 1.5-billion-parameter Qwen models served by Ollama in a second container. Both summaries in this post are real output, including the one that went wrong. I did not point it at my own mailbox, and the Telegram step is described but was not exercised; the summaries went to a file.

What Spark actually promises

I keep the vendor's own words next to anything I rebuild, so I am not arguing with a version of the product I made up. Google announced Spark at I/O on 19 May 2026 for Google AI Ultra subscribers in the United States. It runs on Google's cloud rather than your device, which is the point of the Pichai line, and it connects to Gmail, Calendar, Docs, Slides and Sheets, with third-party apps such as Canva, OpenTable and Instacart through MCP. The product page says it is designed to ask you first before high-stakes actions like spending money or sending emails. I covered the rest, and the price, in the headlines post.

Two things in that description decide my design. Spark holds your mail and calendar on a computer Google runs, and it can act on both, with a confirmation step for the risky ones. My version holds nothing off my machine, and it cannot act on either, with no confirmation step because there is nothing to confirm. That is a worse product and a better agent for me, and I think the difference is worth seeing in code.

The design: three doors, all of them one-way

  1. Mail is read-only at the protocol level. The agent opens the mailbox with IMAP's read-only select and fetches with PEEK, so even a bug in my code cannot mark a message read, move it or delete it. The server enforces this, not my good intentions. On a real mailbox I would also use an app password scoped to read-only where the provider allows it.
  2. The calendar only receives proposals. When the model decides a message proposes a time, the agent writes a tentative .ics file to a folder. I import it, or I do not. Nothing touches the live calendar.
  3. The only output is one summary. A single message a night, to Telegram or to a file. Not one per email, which is how you train yourself to ignore an agent. If the summary does not arrive, that is the alarm.

The agent, in full

This is the whole thing, about a hundred lines of standard-library Python plus one HTTP call to Ollama. No framework, because I want to be able to read every line of something that reads my mail.

inbox_agent.py#!/usr/bin/env python3
"""Inbox and calendar agent, read-only.

Reads unseen mail over IMAP with the mailbox opened read-only, asks a local model (Ollama) to
sort each message into one of four buckets, writes a proposed .ics for anything that looks like a
meeting, and prints (or sends to Telegram) one summary. It never sends mail, never marks anything
read, never creates a calendar event.

Environment:
  IMAP_HOST, IMAP_PORT, IMAP_USER, IMAP_PASS   mailbox (use an app password; the login must be read-only)
  OLLAMA_URL (default http://127.0.0.1:11434), OLLAMA_MODEL (default qwen2.5:0.5b)
  TELEGRAM_TOKEN, TELEGRAM_CHAT                optional; without them the summary goes to stdout
  OUT_DIR (default ./out)                      where proposed .ics files and the summary go
"""
import datetime as dt
import email
import imaplib
import json
import os
import re
import sys
import urllib.request
from email.header import decode_header, make_header
from pathlib import Path

HOST = os.environ["IMAP_HOST"]
PORT = int(os.environ.get("IMAP_PORT", "993"))
USER = os.environ["IMAP_USER"]
PASS = os.environ["IMAP_PASS"]
SSL = os.environ.get("IMAP_SSL", "1") == "1"
OLLAMA = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:0.5b")
OUT = Path(os.environ.get("OUT_DIR", "out"))
BUCKETS = ["needs-reply", "meeting", "newsletter", "ignore"]

PROMPT = """Classify one email into exactly one category and answer with the category name only.
Categories: meeting (proposes or changes a time to meet), newsletter (bulk mail, alerts, receipts, notifications),
needs-reply (a person asks me for something that is not a meeting time), ignore (anything else).

From: {sender}
Subject: {subject}
Body: {body}

Category:"""


def ask(prompt):
    req = urllib.request.Request(OLLAMA + "/api/generate", method="POST",
                                 data=json.dumps({"model": MODEL, "prompt": prompt, "stream": False,
                                                  "options": {"temperature": 0}}).encode(),
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=120) as r:
        return json.load(r)["response"].strip().lower()


def bucket_of(answer):
    """The bucket that appears earliest in the model's answer, not the first in our list: a model that
    echoes the whole list back would otherwise always land on needs-reply."""
    hits = [(answer.find(b), b) for b in BUCKETS if b in answer]
    return min(hits)[1] if hits else "ignore"


def text_of(msg):
    if msg.is_multipart():
        for part in msg.walk():
            if part.get_content_type() == "text/plain":
                return part.get_payload(decode=True).decode(part.get_content_charset() or "utf-8", "replace")
        return ""
    return msg.get_payload(decode=True).decode(msg.get_content_charset() or "utf-8", "replace")


def propose_ics(uid, subject, body, sender):
    """A proposal, not an event: the file sits in OUT until a human imports it."""
    m = re.search(r"(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})", body)
    when = dt.datetime.fromisoformat(m.group(1) + "T" + m.group(2)) if m else None
    if not when:
        return None
    start = when.strftime("%Y%m%dT%H%M%S")
    end = (when + dt.timedelta(minutes=30)).strftime("%Y%m%dT%H%M%S")
    ics = ("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//inbox-agent//EN\r\nBEGIN:VEVENT\r\n"
           "UID:proposal-%s@inbox-agent\r\nDTSTART:%s\r\nDTEND:%s\r\nSUMMARY:%s\r\nDESCRIPTION:Proposed from mail by %s\r\n"
           "STATUS:TENTATIVE\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" % (uid, start, end, subject, sender))
    p = OUT / ("proposal-%s.ics" % uid)
    p.write_text(ics)
    return p


def main():
    OUT.mkdir(exist_ok=True)
    box = (imaplib.IMAP4_SSL if SSL else imaplib.IMAP4)(HOST, PORT)
    box.login(USER, PASS)
    box.select("INBOX", readonly=True)            # the whole point: nothing can be marked or moved
    _, data = box.uid("search", None, "UNSEEN")
    uids = data[0].split()
    fetched = []
    for uid in uids:
        _, msg_data = box.uid("fetch", uid, "(BODY.PEEK[])")   # PEEK: does not set \Seen
        msg = email.message_from_bytes(msg_data[0][1])
        subject = str(make_header(decode_header(msg.get("Subject", ""))))
        sender = msg.get("From", "")
        body = re.sub(r"\s+", " ", text_of(msg))[:500]
        fetched.append((uid.decode(), sender, subject, body))
    box.logout()   # done with the server before any slow model call; an idle IMAP session gets dropped
    rows, proposals = [], []
    for uid, sender, subject, body in fetched:
        b = bucket_of(ask(PROMPT.format(sender=sender, subject=subject, body=body)))
        rows.append((b, sender, subject))
        if b == "meeting":
            p = propose_ics(uid, subject, body, sender)
            if p:
                proposals.append(p.name)
    stamp = dt.datetime.now().strftime("%Y-%m-%d %H:%M")
    lines = ["Inbox summary, %s (%d unread)" % (stamp, len(rows))]
    for b in BUCKETS:
        items = [r for r in rows if r[0] == b]
        if items:
            lines.append("")
            lines.append("%s (%d)" % (b, len(items)))
            lines += ["  - %s: %s" % (r[1], r[2]) for r in items]
    if proposals:
        lines += ["", "Calendar proposals written, not booked: " + ", ".join(proposals)]
    summary = "\n".join(lines)
    (OUT / "summary.txt").write_text(summary)
    tok, chat = os.environ.get("TELEGRAM_TOKEN"), os.environ.get("TELEGRAM_CHAT")
    if tok and chat:
        req = urllib.request.Request("https://api.telegram.org/bot%s/sendMessage" % tok, method="POST",
                                     data=json.dumps({"chat_id": chat, "text": summary}).encode(),
                                     headers={"Content-Type": "application/json"})
        urllib.request.urlopen(req, timeout=30).read()
    print(summary)


if __name__ == "__main__":
    sys.exit(main())

The classification prompt is deliberately dull: one word from a fixed list, temperature zero. Small local models are reliable at this kind of sorting and unreliable at almost everything more open-ended, which is why the model never writes the summary; the code does, from the model's one-word answers.

Running it against a test mailbox

I did not want to test on real mail, so I used a disposable IMAP server in a container and seeded it with six messages that cover the four buckets: a customer asking to move a kickoff, a newsletter, a contract question with a deadline, a budget alert, an intro-call proposal and an automated receipt.

seed_mail.py#!/usr/bin/env python3
"""Drop six sample messages into the test mailbox over SMTP (GreenMail accepts any recipient)."""
import os
import smtplib
from email.message import EmailMessage

HOST = os.environ.get("SMTP_HOST", "ao-mail")
PORT = int(os.environ.get("SMTP_PORT", "3025"))
TO = os.environ.get("MAIL_TO", "lera@example.test")

SAMPLES = [
    ("dana@customer.test", "Can we move the kickoff?",
     "Hi Lera, could we move the kickoff to 2026-10-23 14:00? The team is travelling on Thursday. Dana"),
    ("noreply@newsletter.test", "This week in video marketing",
     "Top stories this week: short-form keeps winning, plus three tools we like. Unsubscribe at the bottom."),
    ("sam@partner.test", "Quick question on the contract",
     "Lera, clause 4 says 30 days but the invoice says 14. Which is right? Need an answer by Friday. Sam"),
    ("alerts@cloud.test", "Budget alert: 80% of monthly budget",
     "Your project has used 80% of its monthly budget. No action is required."),
    ("ravi@agency.test", "Intro call next week",
     "Would 2026-10-27 10:30 work for a 30 minute intro call? I will send a link. Ravi"),
    ("hr@vendor.test", "Your receipt",
     "Thanks for your payment. This is an automated receipt; no reply is needed."),
]

with smtplib.SMTP(HOST, PORT) as s:
    for sender, subject, body in SAMPLES:
        m = EmailMessage()
        m["From"], m["To"], m["Subject"] = sender, TO, subject
        m.set_content(body)
        s.send_message(m)
print("seeded %d messages to %s" % (len(SAMPLES), TO))

Then the agent, pointed at that server and at a 0.5-billion-parameter model, produced this:

Output, qwen2.5:0.5bInbox summary, 2026-10-21 14:33 (6 unread)

newsletter (6)
  - dana@customer.test: Can we move the kickoff?
  - noreply@newsletter.test: This week in video marketing
  - sam@partner.test: Quick question on the contract
  - alerts@cloud.test: Budget alert: 80% of monthly budget
  - ravi@agency.test: Intro call next week
  - hr@vendor.test: Your receipt

That is a failure, and I am leaving it in because it is the most useful thing in this post. The 0.5-billion-parameter model put all six messages in one bucket. In my first run it was needs-reply for everything; after I rewrote the prompt and fixed a parsing bug of my own, it was newsletter for everything. A model that small does not sort, it picks a favourite. The pipeline around it worked exactly as designed: read-only fetch, no flags set, one summary written. The judgement in the middle was not there.

So I ran it again with the next size up, a 1.5-billion-parameter model from the same family, which is still free and still small enough for a laptop or a Pi. The first attempt crashed, and the crash taught me something about the agent rather than the model: I had kept the IMAP connection open while waiting on the model, the bigger model took long enough to load that the server dropped the idle session, and the fetch died with a socket error. The version of the script above is the fixed one: it fetches everything, logs out, and only then starts asking questions. With that change:

Output, qwen2.5:1.5bInbox summary, 2026-10-21 14:42 (6 unread)

needs-reply (2)
  - dana@customer.test: Can we move the kickoff?
  - sam@partner.test: Quick question on the contract

meeting (1)
  - ravi@agency.test: Intro call next week

newsletter (3)
  - noreply@newsletter.test: This week in video marketing
  - alerts@cloud.test: Budget alert: 80% of monthly budget
  - hr@vendor.test: Your receipt

Calendar proposals written, not booked: proposal-5.ics
out/proposal-5.icsBEGIN:VCALENDAR
VERSION:2.0
PRODID:-//inbox-agent//EN
BEGIN:VEVENT
UID:proposal-5@inbox-agent
DTSTART:20261027T103000
DTEND:20261027T110000
SUMMARY:Intro call next week
DESCRIPTION:Proposed from mail by ravi@agency.test
STATUS:TENTATIVE
END:VEVENT
END:VCALENDAR

That is the summary I would want on my phone. Three times the parameters, still free, still fast enough on a CPU, and the sort is right: the two humans asking for things are separated from the three machines sending notifications, Ravi's proposed time became a tentative calendar file, and the receipt was not mistaken for a question. One call I would argue with: Dana's message proposes a new time for the kickoff and the model filed it under needs-reply rather than meeting, so no proposal was written for it. It is a defensible reading, since she is asking rather than confirming, and it is also exactly why the summary lists the subject line and the model never gets to act on its own verdict. The lesson I take from the two runs together is simple: the smallest model that sorts correctly is the one to use, and the only way to find it is to run the sort on mail you know the answers to.

Making it always-on

The agent is a one-shot script, which I think is the right shape for a nightly job: it starts, does its work, exits, and the scheduler brings it back. On Linux that is a systemd timer, with Persistent=true so a night the machine was off is made up the next time it is on. The Linux core page explains every line; the Mac guide does the same with launchd.

systemd units# /etc/systemd/system/inbox-agent.service
[Unit]
Description=Inbox and calendar agent (read-only)
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=agent
WorkingDirectory=/srv/agent
EnvironmentFile=/etc/inbox-agent.env
ExecStart=/srv/agent/venv/bin/python /srv/agent/inbox_agent.py

# /etc/systemd/system/inbox-agent.timer
[Unit]
Description=Run the inbox agent every evening

[Timer]
OnCalendar=*-*-* 21:30:00
Persistent=true

[Install]
WantedBy=timers.target
/etc/inbox-agent.env (owned by root, mode 600)IMAP_HOST=imap.example.com
IMAP_PORT=993
IMAP_USER=lera@example.com
IMAP_PASS=app-password-with-read-only-scope
OLLAMA_URL=http://127.0.0.1:11434
OLLAMA_MODEL=qwen2.5:0.5b
TELEGRAM_TOKEN=123456:bot-token
TELEGRAM_CHAT=987654321
OUT_DIR=/srv/agent/out
Terminalsudo systemd-analyze verify /etc/systemd/system/inbox-agent.service
sudo systemctl enable --now inbox-agent.timer
systemctl list-timers inbox-agent.timer

The secrets file is the one piece of this that holds a credential, and it is a mailbox credential, so it is owned by root, readable only by root, and handed to the service by systemd. The agent's own user cannot read it from a shell, and neither can anything else running as that user.

What I would add next, and what I would not

Next: a second bucket model for the needs-reply pile, so the summary says what each one is asking for in a sentence. That is still read-only. Then draft replies written to a folder, which is still read-only for the mailbox even though it feels like a step towards sending.

Not: sending. Not marking read, not archiving, not creating events. Each of those is a one-way door in the other direction, and the permissions post is the long version of why I keep an agent on the read-only side of it until it has earned a very specific exception. Spark's answer is to ask you first. Mine is to make the question impossible.

Sources

  1. DataCamp, Gemini Spark: Google's always-on AI agent explained (quoting Google's announcement and product page) (accessed October 20, 2026)
  2. Yahoo Tech / WSJ, Google launches Gemini Spark personal AI agent at I/O 2026 (accessed October 20, 2026)
  3. Python documentation, imaplib: IMAP4 protocol client (accessed October 20, 2026)
  4. Ollama, API reference: generate a completion (accessed October 20, 2026)

Written by Lera Leonteva. The run output is from a disposable test mailbox and a local model on the access date, not from my own inbox.

Frequently asked questions

Can an agent read my email without being able to send or delete?

Yes. IMAP lets a client open a mailbox read-only and fetch messages with PEEK, so nothing is marked read, moved or deleted, and the server enforces it. Use an app password, and where the provider offers scoped access, a read-only scope.

Does this inbox agent need a paid model?

No. Sorting messages into a few fixed buckets is within reach of a small local model through Ollama; I used a 0.5-billion-parameter model for the test run. The summary is written by the code, not the model, so there is nothing for a bigger model to improve until you want it to summarise content.

How is this different from Gemini Spark?

Spark runs on Google's cloud, holds your Gmail and Calendar, and can act on both, asking first for high-stakes actions like sending email or spending money. This runs on your machine, holds nothing off it, and cannot act at all: it reads, sorts, proposes and summarises.

How does the calendar part work without touching my calendar?

When a message proposes a time, the agent writes a tentative .ics file to a folder. Importing it is a human step. The live calendar is never written to.

What happens if the agent stops running?

The nightly summary does not arrive, which is the alarm. The timer has Persistent=true so a missed night runs at the next boot, and a watchdog that checks the summary file's age is the next thing I would add.

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.