This guide is built to be handed to an AI assistant, which then walks you through it one step at a time. Claude Code in a terminal, Claude Desktop, and Codex all work. Save this page to your machine, open a fresh assistant session, give it this file, and paste this:
Walk me through Session 2.8 one step at a time.
Give me only the current step. Stop after each step and wait for me to say it's done.
Before each step, check whether it's already done on this machine, and skip it if it is.
Tell me what each command does before I run it.
Start at the beginning.
Then work through it together. Tell the assistant when a step is finished, or paste the error when one isn’t.
Assistant: the rules below govern this whole session. They override any default habit of reading a document and summarizing it.
CLAUDE.md or
AGENTS.md records which one; if it does not, ask once and
do not ask again. Where a step below has a ### Claude Code
version and a ### Codex version, deliver only theirs and do
not mention the other exists. Everywhere else the work is identical, so
say “your assistant” rather than assuming Claude. This is about the tool
the learner installs and works in, not about which assistant is reading
this guide.Run this from ~/ai-training, the working directory you
made in Session 2.1. About 75 minutes.
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
What this does. Lists every MCP server Claude Code has configured for this project and shows whether each one is connected right now.
Step 1 was yours. The rest runs through Claude. If you haven’t handed
it this guide yet, follow “How to run this session” at the top. From
inside ~/ai-training, start claude, give it
this file, and add one line so it knows where you are:
Gmail and Calendar MCPs are connected.
Claude takes over from here, walking through the script build, the launchd schedule, and the failure-email pattern one step at a time.
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)Already done? Run
ls ~/ai-training/briefing.py. If it exists, skip to Step
B.
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
What this does. chmod +x sets the
executable bit on briefing.py, marking it as a file the
shell is allowed to run directly instead of just read. That pairs with
the #!/usr/bin/env python3 line at the top of the file, the
shebang, which tells the system which interpreter should run the rest of
the file. Together they let you invoke the script as
./briefing.py instead of typing python3 every
time. Skip chmod +x and ./briefing.py fails
with a permissions error even though the shebang line is correct.
Worth knowing. Both invocations in this guide, the
manual test below and the launchd job in Step B, call
python3 briefing.py directly, so the executable bit isn’t
load-bearing here. It matters once you or a teammate runs
./briefing.py on its own, which is the more common pattern
outside this guide.
Run it now to test:
python3 ~/ai-training/briefing.py
cat ~/ai-training/briefs/$(date +%Y-%m-%d).md
What this does. python3 runs the script
once, exactly like the launchd job will every morning, and
cat prints the brief it just wrote so you can check the
output immediately.
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.
Already done? Run
launchctl list | grep morning-brief. If it prints a line,
the job is already scheduled. Skip to Step C.
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
What this does. launchctl load reads
the plist and registers it with launchd, the background process that
manages scheduled jobs on macOS. From that point on, launchd owns
triggering the script at 6:00 AM, even after you close the terminal or
reboot the machine. launchctl list | grep morning-brief
confirms the job registered; a matching line means launchd knows about
it. launchctl unload, used later to test the schedule,
reverses this and removes the job.
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
What this does. Same
launchctl unload/load cycle as above:
unloading drops the current schedule, the manual edit swaps in a
near-term trigger, and loading registers it again so you can watch it
fire without waiting until tomorrow.
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.
Already done? Run
grep email_failure ~/ai-training/briefing.py. If it prints
a matching line, this step is already done. Skip ahead to Micro-skills
introduced.
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.
The launchd pattern you just built works. These two tools extend it — one for in-session iteration, one for getting off your laptop entirely.
/loop
— repeat a task inside an active session/loop runs a task on a repeating interval within the
current Claude Code session:
/loop <interval> <task>
What this does. /loop re-runs the given
task on a repeating interval for as long as the session stays open, with
<interval> setting the wait between runs
(30m, 1h). Omit the interval and Claude paces
itself based on the task instead of a fixed clock. Each run is a fresh
pass over the task rather than a resumed one, so keep the task
description self-contained. This is the tool used below to validate
briefing.py before trusting it to launchd.
Examples:
/loop 30m fetch today's FRED releases and append to briefing.md
— runs every 30 minutes until you stop it./loop with no interval lets Claude self-pace — it
decides when to wake up based on the task’s natural cadence.Good for:
/loop 1h /check-briefing a few
times in a session and watch the output across multiple runs. Catch
prompt drift and edge cases before they accumulate silently.fetch_*, run again — without
restarting the session.Practical use here: after briefing.py passes its manual
test, open a Claude session and run
/loop 5m python3 ~/ai-training/briefing.py three times
before loading the launchd plist. If three runs are clean, the schedule
is safe.
/schedule
— cloud agent on a cron schedule/schedule creates a scheduled cloud agent that runs on
Anthropic’s infrastructure — no launchd plist, no laptop required to be
on.
In a Claude Code session, type /schedule and Claude
walks you through:
0 7 * * 1-5
= 7 AM every weekday.The agent fires in the cloud on schedule. You get a notification on each run — success and failure alike. The laptop can be closed, off, or traveling.
Practical use here: once /loop validates the pipeline,
use /schedule to turn briefing.py into a
permanent cloud job. The brief pipeline you built in Step A becomes a
cloud agent that fires at 7 AM every weekday without you doing
anything.
| Method | What it does | Requires laptop? |
|---|---|---|
| launchd / cron | Runs a local shell command on schedule | Yes |
/loop |
Runs a task repeatedly in an active Claude session | Yes (session must be open) |
/schedule |
Creates a cloud agent on a cron schedule | No |
For a morning brief: launchd is fine if your laptop is always on your
desk. /schedule is the upgrade if you travel or close the
lid overnight.