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.
By the end of this session you will have, in
~/ai-training/:
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).”~/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.~/ai-training/briefs/2026-05-07.md — produced by the script
and reviewed.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.
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:
asyncio or
concurrent.futures.unset CLAUDECODE requirement when invoking Claude from a
launchd job). Getting one schedule right teaches you how to schedule the
rest.You need:
claude mcp list shows it
connected.requests available:
pip3 install --user requests.claude mcp list # confirm both Gmail and Calendar show connected
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.
Claude: walk the user through the script build, the schedule install, and the failure-email pattern. Peer-tone. Two rules:
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.
Before any script work:
claude mcp list.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.
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:
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.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.”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.
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())
raiseClaude: 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.
Name these out loud:
ThreadPoolExecutor does the parallelism in plain
Python.claude -p for headless calls. Same
Claude Code, no interactive UI, output goes to stdout. The bridge
between scripted code and Claude’s tool ecosystem.StartCalendarInterval vs
StartInterval. Wall-clock triggers vs since-boot
intervals. Always StartCalendarInterval for “every day at 6
AM.”unset CLAUDECODE in launchd wrappers.
A required hygiene step when invoking Claude from launchd. Forgetting
causes confusing failures that look like the MCP is broken when it
isn’t.briefs/2026-05-07.md, not briefs/today.md.
Yesterday’s brief should still exist tomorrow.main() in
try/except; on failure, send the traceback. This is the one place
auto-send is the right move (alerts to self only).Three things to try this week:
fetch_* are
over-summarizing or under-filtering.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.
Tell the user: “Your instructor uses these to tailor next week’s session.”
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.