The scripts layer
Scripts handle what Claude can’t do directly: read mail from Outlook, write PowerPoint files, save email drafts in a format Outlook can open. Workflows describe the steps; scripts do the actual reading and writing.
The split matters because scripts get rewritten when an integration changes. Workflows shouldn’t have to change with them.
The three scripts
Section titled “The three scripts”| Script | Purpose | Build order | Dependencies |
|---|---|---|---|
build_deck.py | Markdown outline → .pptx using brand template | First | python-pptx |
pull_outlook.py | Read calendar and inbox | Second | AppleScript or Microsoft Graph |
save_draft.py | Write .eml files Outlook can open | Third | None (stdlib only) |
Build the deck script first. It has no integration dependencies — it’s pure Python — and it gives you a working tool the day you install python-pptx. The Outlook scripts depend on a decision about how to talk to Outlook, which can take time to resolve.
scripts/build_deck.py
Section titled “scripts/build_deck.py”The reference implementation:
#!/usr/bin/env python3"""Build a PowerPoint deck from a markdown outline using a template.
Usage: python build_deck.py outline.md output.pptx [--template path/to/template.pptx]
Outline format: # Slide title - Bullet 1 - Bullet 2
## Notes Speaker notes go here."""
import argparsefrom pathlib import Pathfrom pptx import Presentation
def parse_outline(md_text): slides = [] current = None in_notes = False
for line in md_text.splitlines(): line = line.rstrip()
if line.startswith('# ') and not line.startswith('## '): if current: slides.append(current) current = {'title': line[2:].strip(), 'bullets': [], 'notes': ''} in_notes = False elif line.startswith('## Notes'): in_notes = True elif line.startswith('- ') and current and not in_notes: current['bullets'].append(line[2:].strip()) elif in_notes and current and line.strip(): current['notes'] += line + '\n'
if current: slides.append(current) return slides
def build_deck(outline_path, output_path, template_path=None): md = Path(outline_path).read_text() slides_data = parse_outline(md)
prs = Presentation(template_path) if template_path else Presentation() layout = prs.slide_layouts[1] # typically "Title and Content"
for slide_data in slides_data: slide = prs.slides.add_slide(layout)
if slide.shapes.title: slide.shapes.title.text = slide_data['title']
for shape in slide.placeholders: if shape.placeholder_format.idx == 1: tf = shape.text_frame tf.text = slide_data['bullets'][0] if slide_data['bullets'] else '' for bullet in slide_data['bullets'][1:]: p = tf.add_paragraph() p.text = bullet break
if slide_data['notes']: slide.notes_slide.notes_text_frame.text = slide_data['notes'].strip()
prs.save(output_path) print(f"Built deck with {len(slides_data)} slides → {output_path}")
if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('outline') parser.add_argument('output') parser.add_argument('--template', default=None) args = parser.parse_args() build_deck(args.outline, args.output, args.template)Caveats to know going in
Section titled “Caveats to know going in”The placeholder index 1 and slide_layouts[1] assume a standard template structure. Most corporate templates have custom layouts — inspect yours once with a small script that prints prs.slide_layouts and their placeholders, then update these indices.
The script handles title + bullets + notes only. Tables, images, two-column layouts, custom shapes — none of that is here. Add them when you actually need them, not pre-emptively. The 80/20 of operational decks is title plus bullets plus notes.
No error handling, no logging. Deliberate. This is a personal tool you run interactively. If it breaks, Claude Code will see the traceback and fix it. Don’t gold-plate.
scripts/pull_outlook.py
Section titled “scripts/pull_outlook.py”The implementation depends on which path you can use to talk to Outlook. There are two real options.
Option A — Microsoft Graph API
Section titled “Option A — Microsoft Graph API”The clean path. Works for any client (web, mobile, desktop), gives you fields AppleScript can’t easily expose (categories, importance flags, full headers), and survives Outlook UI changes.
Requires an app registration on your tenant with delegated permissions: Mail.Read, Mail.ReadWrite, Mail.Send, Calendars.Read. On corporate tenants, this needs IT approval — file the ticket and expect days to weeks for response.
Implementation uses msal for auth and requests for API calls. Standard OAuth flow, token refresh, paginated mail queries. The structure is straightforward; the politics of getting approval is the hard part.
Option B — AppleScript against Outlook for Mac
Section titled “Option B — AppleScript against Outlook for Mac”The fallback. Works today, no IT involvement, runs against the desktop app you already have installed.
Limited to what the desktop app exposes via AppleScript. Reading mail and creating drafts is straightforward; some metadata is harder to access; sending programmatically is possible but you shouldn’t do it anyway (drafts only, always).
Implementation uses subprocess to run osascript queries that return mail and calendar data. Less elegant than Graph, but works the day you write it.
Recommendation
Section titled “Recommendation”Start with AppleScript. File the IT ticket for Graph in parallel. Migrate when Graph is approved. The AppleScript fallback unblocks you immediately; the Graph migration is invisible to your workflows because the script’s interface stays the same.
Don’t write both implementations. Write the one that’s currently unblocked, and migrate when needed.
scripts/save_draft.py
Section titled “scripts/save_draft.py”The simplest script. Takes a dict with to, subject, body and writes a .eml file Outlook can open with one click.
#!/usr/bin/env python3"""Save an email draft as a .eml file Outlook can open.
Usage: python save_draft.py --to "name@example.com" --subject "Re: thing" --body "Draft body here." --output drafts/draft.eml"""
import argparsefrom email.message import EmailMessagefrom pathlib import Path
def save_draft(to_addr, subject, body, output_path): msg = EmailMessage() msg['To'] = to_addr msg['Subject'] = subject msg.set_content(body)
Path(output_path).write_bytes(bytes(msg)) print(f"Draft saved → {output_path}")
if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('--to', required=True) parser.add_argument('--subject', required=True) parser.add_argument('--body', required=True) parser.add_argument('--output', required=True) args = parser.parse_args() save_draft(args.to, args.subject, args.body, args.output)This is roughly fifteen lines of real work. Resist the urge to add HTML body support, attachments, threading headers, or anything else until a real workflow needs it.
Why scripts stay small
Section titled “Why scripts stay small”Each script does one thing. Each is short enough that the implementation is obvious from reading it. Each has minimal dependencies.
The temptation is to build a scripts/lib/ shared module with helpers across scripts. Don’t. Three scripts of fifty lines each are easier to maintain than three scripts of thirty lines each plus a hundred-line shared module that needs versioning. The cost of a small amount of duplication across three personal scripts is much lower than the cost of a small framework you have to maintain.
If a script grows past 150 lines, the right move is usually to split it into two scripts rather than to add abstraction.