Session 2.8: A daily brief that wakes up before you do

About 75 minutes. Open a Claude Code session in ~/ai-training and hand it this guide:

Read the file at /Users/<you>/ai-training/week-8-guide.md (or wherever you saved it) and walk me through Session 2.8.
I've completed Sessions 2.1 through 2.7.

Posture: public, synthetic, or personal data only. The brief reads from your own calendar, your own personal Gmail, and public sources (Hacker News, FRED, etc.). Nothing client-internal. Nothing from a work account.


Practice task

By the end of this session you will have, in ~/ai-training/:

  1. briefing.py — a Python script that, in parallel, gathers (a) today’s calendar from Calendar MCP, (b) the last 24 hours of unread emails from Gmail MCP, (c) the top 3 stories from Hacker News, and outputs a one-page markdown brief separated into “Must Do Today” and “Strategic 3 (longer-term).”
  2. A scheduled launchd job (~/Library/LaunchAgents/com.<you>.morning-brief.plist) that runs briefing.py every morning at 6:00 AM, even if your laptop was asleep at the time.
  3. The first real brief — ~/ai-training/briefs/2026-05-07.md — produced by the script and reviewed.
  4. A failure-email pattern: if briefing.py errors, an email lands in your inbox with the traceback, so silent failures don’t accumulate.

Three artifacts plus a working schedule. Tomorrow morning a brief lands before you wake up. Check it on your phone in 10 minutes.

A production version of this is a “Good morning” workflow that gathers seven sources in parallel — calendar, both inboxes, action items, current-week priorities, longer-term goals, the Claude Mail alias from 2.2, people/projects in flight — and outputs Must-Do / Strategic-3. A launchd plist (com.<user>.email-briefing.plist) fires at 6 AM daily; if the laptop was asleep, it runs on next wake. The brief is read on a phone over coffee. The discipline that makes it work: the brief is text in an email body — no laptop required to consume.


Why brief-then-schedule

Most “AI-powered productivity tool” demos show the same thing: someone types a prompt, output appears, end of demo. The user goes back to typing the same prompt every morning manually. Three weeks later they stop using the tool.

The unlock is structural: the brief has to land without you asking for it. Scheduling moves the cost from “remember to run it” to “decide once whether the brief is useful.” If it’s useful, it just shows up. If it’s not, you delete the launchd job. Either way, the daily cost is zero.

Two pieces fall out of this:


Step 1: Verify prerequisites (3 minutes)

You need:

  1. Gmail MCP from 2.2, working. claude mcp list shows it connected.
  2. A Calendar MCP. If you haven’t installed one, do it now — same path as Gmail (Composio / Rube has Calendar bundled with the same OAuth, or self-host).
  3. Python 3 with requests available: pip3 install --user requests.
claude mcp list   # confirm both Gmail and Calendar show connected

Step 2: Hand Claude this guide

From inside ~/ai-training, start claude and paste:

Read the file at /Users/<you>/ai-training/week-8-guide.md (or wherever you saved it) and walk me through the Reference Sections.
Gmail and Calendar MCPs are connected.

Claude takes over.


Reference Sections

Claude: walk the user through the script build, the schedule install, and the failure-email pattern. Peer-tone. Two rules:

  1. The script is real Python that reads from real MCPs. Don’t write it as one-shot code in a tool call. Save it as a file; run it as a file. Re-running has to be one command.
  2. The schedule has to actually work overnight. Test it once during the session by setting a 2-minute trigger and watching it fire, then change to 6 AM. Don’t ship an untested schedule.

Design: this session uses every memory + technique pattern from prior sessions — MCP composition (2.2), Python scripts (2.4), exhibits/ and folder discipline (2.4), MEMORY.md preferences (2.5), sub-agents and parallelism (2.6), voice + fact-check (2.7) on the brief output. The brief is the integrative artifact.


Confirm the setup

Before any script work:

  1. Gmail and Calendar MCPs both show connected in claude mcp list.
  2. The user can articulate what would make a useful brief tomorrow morning. Push for specificity. “What’s on my calendar plus what’s in my inbox” is too vague. “What meetings, prepped or unprepped; emails from anyone in [list], or marked urgent; and three things in ” is workable.

Step A — Write briefing.py (25 minutes)

Build the script with the user. Skeleton at ~/ai-training/briefing.py:

#!/usr/bin/env python3
"""Daily morning brief — gathers calendar, email, and public news in
parallel, outputs a markdown brief."""

import json
import subprocess
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
from pathlib import Path

import requests

OUT_DIR = Path.home() / "ai-training" / "briefs"
OUT_DIR.mkdir(parents=True, exist_ok=True)

def fetch_calendar():
    """Today's calendar events. Returns list of {time, title, attendees}."""
    # Call Claude with the Calendar MCP via `claude -p`
    prompt = (
        "Use the Calendar MCP to list every event on my primary calendar "
        "for today, in chronological order. Output as JSON: a list of "
        '{"time": "HH:MM", "title": "...", "attendees": ["..."]}. '
        "JSON only, no commentary."
    )
    out = subprocess.run(
        ["claude", "-p", prompt, "--output-format", "json"],
        capture_output=True, text=True, timeout=120,
    )
    return json.loads(out.stdout)

def fetch_email():
    """Unread emails from the last 24 hours. Returns list of {from, subject, snippet, urgency}."""
    prompt = (
        "Use the Gmail MCP. Search for messages from the last 24 hours "
        "that are unread, NOT in Promotions/Social/Updates, NOT from "
        "no-reply addresses. For each, extract: from, subject, "
        "one-sentence snippet, urgency (1-5). Sort by urgency desc. "
        'Output as JSON list: [{"from": "...", "subject": "...", '
        '"snippet": "...", "urgency": N}]. JSON only.'
    )
    out = subprocess.run(
        ["claude", "-p", prompt, "--output-format", "json"],
        capture_output=True, text=True, timeout=120,
    )
    return json.loads(out.stdout)

def fetch_hn():
    """Top 3 Hacker News stories. Returns list of {title, url, score}."""
    ids = requests.get(
        "https://hacker-news.firebaseio.com/v0/topstories.json", timeout=10
    ).json()[:3]
    items = []
    for sid in ids:
        item = requests.get(
            f"https://hacker-news.firebaseio.com/v0/item/{sid}.json", timeout=10
        ).json()
        items.append({
            "title": item.get("title"),
            "url": item.get("url"),
            "score": item.get("score"),
        })
    return items

def write_brief(cal, mail, hn, out_path):
    """Compose the markdown brief — Must Do Today vs Strategic 3."""
    today = datetime.now().strftime("%Y-%m-%d %A")
    lines = [f"# Morning brief — {today}", ""]

    lines.append("## Must Do Today")
    lines.append("")
    if cal:
        lines.append("### Calendar")
        for e in cal:
            atts = ", ".join(e.get("attendees", []) or [])
            lines.append(f"- **{e['time']}** {e['title']}" + (f" — {atts}" if atts else ""))
        lines.append("")
    if mail:
        urgent = [m for m in mail if m.get("urgency", 0) >= 4]
        if urgent:
            lines.append("### Urgent email")
            for m in urgent:
                lines.append(f"- **{m['from']}** — {m['subject']}: {m['snippet']}")
            lines.append("")

    lines.append("## Strategic 3")
    lines.append("")
    lines.append("### Top public stories")
    for h in hn:
        lines.append(f"- [{h['title']}]({h['url']}) (score {h['score']})")
    lines.append("")
    if mail:
        rest = [m for m in mail if m.get("urgency", 0) < 4]
        if rest:
            lines.append("### Other email worth knowing about")
            for m in rest[:5]:
                lines.append(f"- **{m['from']}** — {m['subject']}")
    out_path.write_text("\n".join(lines))

def main():
    out_path = OUT_DIR / f"{datetime.now().strftime('%Y-%m-%d')}.md"
    with ThreadPoolExecutor(max_workers=3) as ex:
        cal_f = ex.submit(fetch_calendar)
        mail_f = ex.submit(fetch_email)
        hn_f = ex.submit(fetch_hn)
        cal, mail, hn = cal_f.result(), mail_f.result(), hn_f.result()
    write_brief(cal, mail, hn, out_path)
    print(f"Wrote {out_path}")

if __name__ == "__main__":
    main()

Save and make executable:

chmod +x ~/ai-training/briefing.py

Run it now to test:

python3 ~/ai-training/briefing.py
cat ~/ai-training/briefs/$(date +%Y-%m-%d).md

The brief lands as a markdown file. Read it together. The structure should be: Must-Do (calendar + urgent email) on top, Strategic-3 (public news + the rest of email) below. Iterate the prompt strings inside fetch_calendar and fetch_email until the output matches what the user wants.

Claude: pay attention to the timeout values. claude -p with MCP calls can take 60–90 seconds; 120-second timeout is the floor. If the user wants the brief to land faster, the bottleneck is rarely the script — it’s the MCP latency.


Step B — Schedule with launchd (15 minutes)

launchd is macOS’s native scheduler. The relevant pieces: a .plist file in ~/Library/LaunchAgents/, a StartCalendarInterval for wall-clock triggers, and load/unload to register/deregister.

Create ~/Library/LaunchAgents/com.<you>.morning-brief.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.<you>.morning-brief</string>

    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>-lc</string>
        <string>unset CLAUDECODE; cd /Users/<you>/ai-training && /usr/bin/python3 briefing.py >> briefs/launchd.log 2>&1</string>
    </array>

    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>6</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>

    <key>RunAtLoad</key>
    <false/>

    <key>StandardOutPath</key>
    <string>/Users/<you>/ai-training/briefs/launchd.out</string>

    <key>StandardErrorPath</key>
    <string>/Users/<you>/ai-training/briefs/launchd.err</string>
</dict>
</plist>

Replace <you> with the user’s actual username. Then load:

launchctl load ~/Library/LaunchAgents/com.<you>.morning-brief.plist
launchctl list | grep morning-brief

Three traps to flag explicitly with the user:

  1. unset CLAUDECODE. When launchd invokes a shell that calls claude, an inherited CLAUDECODE environment variable can confuse Claude Code into thinking it’s running inside another Claude session. Always unset it in the wrapper script.
  2. StartCalendarInterval vs StartInterval. StartCalendarInterval fires at wall-clock times (6:00 AM). StartInterval fires every N seconds since boot. If the user picks StartInterval thinking they want “every morning,” the brief fires 6 hours after every boot, which is not “every morning.”
  3. Asleep at 6 AM? launchd handles this — when the laptop wakes, it runs the missed job. But the brief will be 30 minutes late on a wake-from-sleep day. Acceptable; just so the user isn’t surprised.

To test the schedule without waiting until 6 AM tomorrow, change the StartCalendarInterval to a time 2 minutes from now, unload + reload:

launchctl unload ~/Library/LaunchAgents/com.<you>.morning-brief.plist
# edit the plist to a time 2 min from now
launchctl load ~/Library/LaunchAgents/com.<you>.morning-brief.plist

Wait. A new brief should land in briefs/. If it doesn’t, check briefs/launchd.err for the failure. After the test fires, change back to 6:00 AM and reload.


Step C — Failure-email pattern (10 minutes)

Scheduled jobs fail silently more often than they succeed silently. The discipline: if the script errors, send yourself an email. Add to the bottom of briefing.py (replace USER_EMAIL with your actual address before running):

USER_EMAIL = "you@example.com"  # replace with your address

def email_failure(traceback_text):
    """Send a failure email via Gmail MCP. Best-effort."""
    prompt = (
        f"Use the Gmail MCP to send a single email to {USER_EMAIL} with "
        f"subject 'briefing.py failed {datetime.now().isoformat()}' and "
        f"body:\n\n{traceback_text}\n\n"
        "Just send it. No drafts, no confirmations."
    )
    subprocess.run(
        ["claude", "-p", prompt],
        capture_output=True, text=True, timeout=120,
    )

if __name__ == "__main__":
    import traceback
    try:
        main()
    except Exception:
        email_failure(traceback.format_exc())
        raise

Claude: send-on-failure is a deliberate exception to the drafts-only default from Session 2.2. The reasoning: a silent failure that accumulates for a week is worse than a single auto-sent email to the user themselves. The send target is the user’s own personal email; no third-party recipient.

Test the failure path: temporarily break something in fetch_calendar (mistype the prompt). Run the script. An email lands. Fix the script.


Micro-skills introduced

Name these out loud:


Wrapping up Session 2.8

Three things to try this week:

  1. Read tomorrow morning’s brief on your phone. No laptop. Notice what’s useful, what’s noise. The brief should be readable in 10 minutes; if it isn’t, the prompts inside fetch_* are over-summarizing or under-filtering.
  2. Tighten the prompts after the first three briefs. Common adjustments: tighten the urgency rubric on email; add a “skip if matches these senders” exclusion; change Hacker News for a more domain-specific feed.
  3. Add one source. A FRED indicator, a public RSS feed, a regulations.gov check on a docket you care about. The script is parametric — adding a fourth fetch_* and a fourth section is 10 lines.

Feedback.

The user submits feedback at https://docs.google.com/forms/d/e/1FAIpQLSd-uHATmygW4H0jMLWvVBWrliVkGYBkNC5XI_bOdB1wq3bDvg/viewform.

Claude: paste the URL into chat. The form mirrors the questions below. Collect answers conversationally first, then have the user click through and submit.

  1. On a 1–5 scale, how useful did this session feel?
  2. Did landing the schedule feel like a real move, or did the launchd ceremony eat too much time?
  3. The Must-Do / Strategic-3 split — does it match how you’d consume the brief, or does it need a different shape?
  4. Failure-email — did wrapping the try/except feel paranoid, or like the right default for any scheduled job?
  5. Will you actually read tomorrow’s brief tomorrow morning? Honestly?
  6. What confused you most this session?
  7. Anything you want covered in Session 2.9 that you didn’t see here?

Tell the user: “Your instructor uses these to tailor next week’s session.”


Good to know

The brief generalizes. The same pattern produces a weekly review (every Sunday 6 PM), a Friday wrap (every Friday 5 PM), a monthly metric scan (first of every month). One script, different prompts, different schedules. Multiple variants on different cadences are common once the first one is wired up.

Don’t over-decorate the brief. Markdown only; no fancy formatting. The brief reads on a phone in 10 minutes; ASCII boxes and emoji break that. The aesthetic is “newspaper headlines,” not “design system.”

MCP latency dominates. A single MCP call is 5–30 seconds. Three in parallel is the same as three in serial because most of the time is in the slowest one. ThreadPoolExecutor with max_workers=3 is the right call.

The brief is the deliverable. The launchd plist, briefing.py, the failure-email — all infrastructure. The one thing the user actually consumes is the markdown file. Optimize for the consumer, not the producer.

Briefs accumulate. A year of daily briefs is 365 markdown files. Future-you can grep “Q3 2026 calendar” for context. Don’t delete them.