Skip to content

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.

ScriptPurposeBuild orderDependencies
build_deck.pyMarkdown outline → .pptx using brand templateFirstpython-pptx
pull_outlook.pyRead calendar and inboxSecondAppleScript or Microsoft Graph
save_draft.pyWrite .eml files Outlook can openThirdNone (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.

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 argparse
from pathlib import Path
from 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)

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.

The implementation depends on which path you can use to talk to Outlook. There are two real options.

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.

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.

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 argparse
from email.message import EmailMessage
from 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.

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.