Most executive assistants spend the first hour of the day doing something no human should have to: reading everything, so the person they support does not have to read everything.
That specific task — ingesting a calendar and an inbox, working out what actually matters, and presenting it in priority order — is well suited to an agent. This article walks through building one, connected to Outlook, producing a dashboard each morning.
It takes about 45 minutes to set up. You need some comfort with a terminal, but not software engineering experience.
What You Are Building
A script that runs at 7am, signs into Outlook, reads today’s meetings and the recent inbox, sends both to Claude for analysis, and opens a dashboard showing what needs attention, what can be delegated, and where the day is going to go wrong.
Crucially, it is read-only. It does not send email, accept meetings or reply to anyone. That is deliberate, and I will come back to why.
Before You Start: The Permissions Conversation
Step 1: Register the App in Microsoft Entra
This gives your script an identity Microsoft recognises. It does not give it your password.
Executive PA Agent. Under supported account types choose Accounts in this organizational directory only. Leave the redirect URI blank.Mail.Read and Calendars.Read, then click Grant admin consent.Step 2: Install the Dependencies
pip install msal requests anthropic
Step 3: Set Your Credentials
These go in environment variables, never in the script itself. Windows:
setx MS_CLIENT_ID "your-application-client-id"
setx MS_TENANT_ID "your-directory-tenant-id"
setx ANTHROPIC_API_KEY "your-anthropic-api-key"
Mac or Linux — add these to your ~/.zshrc or ~/.bashrc:
export MS_CLIENT_ID="your-application-client-id"
export MS_TENANT_ID="your-directory-tenant-id"
export ANTHROPIC_API_KEY="your-anthropic-api-key"
Step 4: The Outlook Connection
Create a folder for the project, then save this as part1_auth.py. It uses Microsoft’s device code flow: you sign in once in a browser, and the token is cached so you are not asked again.
"""
Executive PA Agent — Part 1: Outlook connection
Signs in once via Microsoft device code flow, then caches the token.
No password is ever stored by this script.
"""
import os, json, atexit
import msal
CLIENT_ID = os.environ["MS_CLIENT_ID"]
TENANT_ID = os.environ["MS_TENANT_ID"]
AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
SCOPES = ["Mail.Read", "Calendars.Read"]
CACHE_FILE = "token_cache.bin"
def get_token():
cache = msal.SerializableTokenCache()
if os.path.exists(CACHE_FILE):
cache.deserialize(open(CACHE_FILE, "r").read())
atexit.register(lambda: open(CACHE_FILE, "w").write(cache.serialize())
if cache.has_state_changed else None)
app = msal.PublicClientApplication(
CLIENT_ID, authority=AUTHORITY, token_cache=cache)
accounts = app.get_accounts()
result = app.acquire_token_silent(SCOPES, account=accounts[0]) if accounts else None
if not result:
flow = app.initiate_device_flow(scopes=SCOPES)
if "user_code" not in flow:
raise RuntimeError("Device flow failed: " + json.dumps(flow, indent=2))
print("\n" + flow["message"] + "\n")
result = app.acquire_token_by_device_flow(flow)
if "access_token" not in result:
raise RuntimeError(result.get("error_description", "Authentication failed"))
return result["access_token"]
if __name__ == "__main__":
get_token()
print("Connected to Outlook. Token cached — you will not be asked again.")
Test it now, before adding anything else:
python part1_auth.py
You will see a code and a URL. Open the URL, enter the code, sign in. If it prints "Connected to Outlook", the hardest part is done.
Step 5: Pull the Calendar and Inbox
Save this as part2_fetch.py. Change TIMEZONE if you are outside the Gulf — it takes a Windows timezone name.
"""
Executive PA Agent — Part 2: Pull today's calendar and inbox from Outlook
"""
import requests, datetime
GRAPH = "https://graph.microsoft.com/v1.0"
TIMEZONE = "Arabian Standard Time" # change to your Windows timezone name
def _headers(token):
return {
"Authorization": f"Bearer {token}",
"Prefer": f'outlook.timezone="{TIMEZONE}"',
}
def get_calendar(token, days_ahead=1):
start = datetime.datetime.now(datetime.timezone.utc).replace(
hour=0, minute=0, second=0, microsecond=0)
end = start + datetime.timedelta(days=days_ahead)
url = (f"{GRAPH}/me/calendarView"
f"?startDateTime={start.isoformat()}"
f"&endDateTime={end.isoformat()}"
f"&$orderby=start/dateTime&$top=50"
f"&$select=subject,start,end,location,attendees,isAllDay,organizer,bodyPreview")
r = requests.get(url, headers=_headers(token), timeout=30)
r.raise_for_status()
events = []
for e in r.json().get("value", []):
events.append({
"subject": e.get("subject", "(no subject)"),
"start": e.get("start", {}).get("dateTime", "")[:16].replace("T", " "),
"end": e.get("end", {}).get("dateTime", "")[:16].replace("T", " "),
"location": (e.get("location") or {}).get("displayName", ""),
"organiser": (e.get("organizer") or {}).get("emailAddress", {}).get("name", ""),
"attendees": [a.get("emailAddress", {}).get("name", "")
for a in e.get("attendees", [])][:8],
"all_day": e.get("isAllDay", False),
"preview": (e.get("bodyPreview") or "")[:300],
})
return events
def get_inbox(token, top=40):
url = (f"{GRAPH}/me/mailFolders/inbox/messages"
f"?$top={top}&$orderby=receivedDateTime desc"
f"&$select=subject,from,receivedDateTime,bodyPreview,isRead,importance,hasAttachments")
r = requests.get(url, headers=_headers(token), timeout=30)
r.raise_for_status()
mail = []
for m in r.json().get("value", []):
mail.append({
"subject": m.get("subject", "(no subject)"),
"from": (m.get("from") or {}).get("emailAddress", {}).get("name", ""),
"from_email": (m.get("from") or {}).get("emailAddress", {}).get("address", ""),
"received": m.get("receivedDateTime", "")[:16].replace("T", " "),
"unread": not m.get("isRead", True),
"importance": m.get("importance", "normal"),
"attachment": m.get("hasAttachments", False),
"preview": (m.get("bodyPreview") or "")[:400],
})
return mail
Step 6: Let Claude Read the Day
Save this as part3_analyse.py. The system prompt is doing most of the work here, and it is worth reading rather than skimming — it is what turns a list into a judgement.
"""
Executive PA Agent — Part 3: Claude reads the day and decides what matters
"""
import os, json
from anthropic import Anthropic
client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
MODEL = "claude-sonnet-5"
SYSTEM = """You are the executive assistant to the CEO of a corporate training
company operating across the UAE, KSA and wider MENA region.
You are briefing them at the start of the day. Your job is judgement, not listing.
Rules:
- Work only from the calendar and inbox data provided. Never invent a meeting,
sender or commitment.
- Prioritise by consequence. A client about to churn outranks an internal update.
- Flag anything time-critical that will be missed if not actioned today.
- Distinguish what needs the CEO personally from what should be delegated.
- Be direct. They would rather read an uncomfortable flag than a tidy summary.
- If two meetings clash or there is no gap between them, say so.
- Output valid JSON only. No commentary outside the JSON.
"""
def analyse_day(events, mail):
prompt = f"""Today's calendar:
{json.dumps(events, indent=2)}
Inbox (most recent first):
{json.dumps(mail, indent=2)}
Produce the morning brief.
Return JSON exactly in this shape:
{{
"headline": "one sentence describing the shape of the day",
"focus": [
{{"item": "...", "why_it_matters": "...", "when": "..."}}
],
"meetings": [
{{"time": "...", "subject": "...", "prep_needed": "...",
"who": "...", "risk": "none|clash|no_prep_time|back_to_back"}}
],
"emails_needing_you": [
{{"from": "...", "subject": "...", "why": "...",
"suggested_action": "...", "urgency": "today|this_week|fyi"}}
],
"delegate": [
{{"item": "...", "to_whom_type": "...", "reason": "..."}}
],
"watch_outs": ["..."],
"quiet_time_available": "description of any genuine gaps for focused work"
}}"""
r = client.messages.create(
model=MODEL, max_tokens=6000, system=SYSTEM,
messages=[{"role": "user", "content": prompt}],
)
text = "".join(b.text for b in r.content if b.type == "text")
return json.loads(text[text.find("{"):text.rfind("}") + 1])
Three instructions in that prompt matter more than the rest. Never invent a meeting or sender prevents the most dangerous failure mode. Prioritise by consequence stops it simply ordering things chronologically. And they would rather read an uncomfortable flag than a tidy summary is what stops it producing a diplomatic non-answer.
Step 7: Build the Dashboard
Save this as part4_dashboard.py. It renders the brief as a self-contained HTML file and opens it in the browser.
"""
Executive PA Agent — Part 4: Render the brief as a dashboard
"""
import datetime, html, webbrowser, os
NAVY, GOLD = "#0B1F3A", "#C8A84B"
RISK = {"clash": "#dc2626", "no_prep_time": "#d97706",
"back_to_back": "#d97706", "none": "#94a3b8"}
URG = {"today": "#dc2626", "this_week": "#d97706", "fyi": "#94a3b8"}
def _esc(v):
return html.escape(str(v or ""))
def build_dashboard(brief, out="dashboard.html", open_browser=True):
today = datetime.datetime.now().strftime("%A %d %B %Y")
focus = "".join(
f"<div class='card'><div class='card-t'>{_esc(f.get('item'))}</div>"
f"<div class='card-w'>{_esc(f.get('why_it_matters'))}</div>"
f"<div class='chip'>{_esc(f.get('when'))}</div></div>"
for f in brief.get("focus", []))
meetings = "".join(
f"<tr><td class='t'>{_esc(m.get('time'))}</td>"
f"<td><b>{_esc(m.get('subject'))}</b>"
f"<div class='sub'>{_esc(m.get('who'))}</div>"
f"{('<div class=prep>Prep: ' + _esc(m.get('prep_needed')) + '</div>') if m.get('prep_needed') else ''}</td>"
f"<td><span class='dot' style='background:{RISK.get(m.get('risk','none'), '#94a3b8')}'></span>"
f"{_esc(str(m.get('risk','none')).replace('_',' '))}</td></tr>"
for m in brief.get("meetings", []))
emails = "".join(
f"<div class='mail'><div class='mail-h'>"
f"<span class='badge' style='background:{URG.get(e.get('urgency','fyi'), '#94a3b8')}'>"
f"{_esc(str(e.get('urgency','fyi')).replace('_',' '))}</span>"
f"<b>{_esc(e.get('from'))}</b></div>"
f"<div class='mail-s'>{_esc(e.get('subject'))}</div>"
f"<div class='mail-w'>{_esc(e.get('why'))}</div>"
f"<div class='mail-a'>→ {_esc(e.get('suggested_action'))}</div></div>"
for e in brief.get("emails_needing_you", []))
delegate = "".join(
f"<li><b>{_esc(d.get('item'))}</b> — {_esc(d.get('to_whom_type'))}"
f"<div class='sub'>{_esc(d.get('reason'))}</div></li>"
for d in brief.get("delegate", []))
watch = "".join(f"<li>{_esc(w)}</li>" for w in brief.get("watch_outs", []))
page = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Daily Brief — {today}</title>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:-apple-system,Segoe UI,Arial,sans-serif;background:#f3f5f8;color:#1a2332;padding:28px}}
.wrap{{max-width:1180px;margin:0 auto}}
header{{background:{NAVY};color:#fff;padding:26px 30px;border-radius:12px 12px 0 0;border-bottom:3px solid {GOLD}}}
header h1{{font-size:21px;font-weight:600}}
header .date{{color:rgba(255,255,255,.55);font-size:13px;margin-top:3px}}
.headline{{background:#fff;padding:20px 30px;font-size:16px;line-height:1.6;border-left:4px solid {GOLD}}}
.grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}}
@media(max-width:900px){{.grid{{grid-template-columns:1fr}}}}
.panel{{background:#fff;border-radius:10px;padding:22px;border:1px solid #e3e8ef}}
.panel h2{{font-size:12px;text-transform:uppercase;letter-spacing:.12em;color:#7a8798;margin-bottom:14px}}
.card{{border-left:3px solid {GOLD};padding:10px 14px;margin-bottom:12px;background:#fafbfc}}
.card-t{{font-weight:600;font-size:14.5px}}
.card-w{{font-size:13px;color:#5a6b7f;margin-top:3px;line-height:1.55}}
.chip{{display:inline-block;background:#eef1f5;color:#5a6b7f;font-size:11px;padding:2px 8px;border-radius:10px;margin-top:6px}}
table{{width:100%;border-collapse:collapse;font-size:13.5px}}
td{{padding:11px 8px;border-bottom:1px solid #eef1f5;vertical-align:top}}
td.t{{white-space:nowrap;color:{GOLD};font-weight:700;width:64px}}
.sub{{font-size:12px;color:#8b98a8;margin-top:2px}}
.prep{{font-size:12px;color:#b45309;margin-top:4px}}
.dot{{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}}
.mail{{border:1px solid #e3e8ef;border-radius:8px;padding:13px;margin-bottom:11px}}
.mail-h{{display:flex;align-items:center;gap:8px;font-size:13px}}
.badge{{color:#fff;font-size:10px;font-weight:700;padding:2px 7px;border-radius:4px;text-transform:uppercase}}
.mail-s{{font-size:13.5px;font-weight:600;margin:5px 0 3px}}
.mail-w{{font-size:12.5px;color:#5a6b7f;line-height:1.5}}
.mail-a{{font-size:12.5px;color:{NAVY};font-weight:600;margin-top:6px}}
ul{{margin-left:17px}} li{{font-size:13.5px;margin-bottom:9px;line-height:1.5}}
.quiet{{background:#f0fdf4;border:1px solid #86efac;color:#15803d;padding:14px 18px;border-radius:8px;margin-top:18px;font-size:13.5px}}
footer{{text-align:center;color:#98a4b3;font-size:11.5px;margin-top:22px}}
</style></head><body><div class="wrap">
<header><h1>Daily Brief</h1><div class="date">{today}</div></header>
<div class="headline">{_esc(brief.get('headline'))}</div>
<div class="grid">
<div class="panel"><h2>Today’s Focus</h2>{focus or '<p class=sub>Nothing flagged.</p>'}</div>
<div class="panel"><h2>Schedule</h2><table>{meetings or '<tr><td class=sub>No meetings today.</td></tr>'}</table></div>
<div class="panel"><h2>Needs You Personally</h2>{emails or '<p class=sub>Nothing requiring you directly.</p>'}</div>
<div class="panel"><h2>Delegate</h2><ul>{delegate or '<li class=sub>Nothing to delegate.</li>'}</ul>
<h2 style="margin-top:20px">Watch-Outs</h2><ul>{watch or '<li class=sub>None flagged.</li>'}</ul></div>
</div>
<div class="quiet"><b>Focus time:</b> {_esc(brief.get('quiet_time_available'))}</div>
<footer>Generated {datetime.datetime.now().strftime('%H:%M')} · Drafted by Claude — verify before acting</footer>
</div></body></html>"""
with open(out, "w", encoding="utf-8") as f:
f.write(page)
if open_browser:
webbrowser.open("file://" + os.path.abspath(out))
return out
Step 8: Tie It Together
Save this as runner.py.
"""Executive PA Agent — main runner"""
from part1_auth import get_token
from part2_fetch import get_calendar, get_inbox
from part3_analyse import analyse_day
from part4_dashboard import build_dashboard
def main():
print("Connecting to Outlook...")
token = get_token()
print("Reading calendar and inbox...")
events = get_calendar(token)
mail = get_inbox(token)
print(f" {len(events)} meetings, {len(mail)} emails")
print("Analysing the day...")
brief = analyse_day(events, mail)
path = build_dashboard(brief)
print(f"Dashboard ready: {path}")
if __name__ == "__main__":
main()
Run it:
python runner.py
Step 9: Schedule It
Windows — run Command Prompt as administrator:
schtasks /create /tn "Daily Brief" /tr "python C:\PA\runner.py" /sc daily /st 07:00
Mac or Linux:
# Run: crontab -e then add this line
0 7 * * 1-5 cd /Users/you/PA && /usr/bin/python3 runner.py
What You Get
A dashboard with six panels: the headline shape of the day, today’s focus items with reasons, the schedule with clash and prep-time warnings, emails that genuinely need the CEO personally, items that should be delegated, and any real gaps for focused work.
The clash detection tends to earn its keep fastest. An agent that notices two meetings are back to back with no travel time, or that a client review has no prep slot booked before it, catches things a calendar view simply does not surface.
The value is not that it summarises your inbox. It is that it tells you which three things will go wrong today if you do not act — and which of those actually needs you.
Why It Is Read-Only, and Why That Should Stay
It would not be difficult to let this agent send replies, decline meetings or forward items. Resist that, at least at first.
An agent that drafts a brief and gets something wrong wastes two minutes of reading. An agent that sends an email on a CEO’s behalf and gets something wrong damages a client relationship, and nobody finds out until the damage is done.
If you do extend it to take actions, every irreversible one needs an explicit approval step — the agent proposes, a human confirms, and only then does it act. That is the single most important design principle in agent building, and it is the one most commonly skipped in the rush to demonstrate autonomy.
Sensible Extensions
- Add Teams messages via the
Chat.Readpermission, so the brief covers where the real conversations happen - Add your CRM so the agent knows which meetings are with at-risk accounts and can weight them accordingly
- Add yesterday’s commitments by reading sent items, so it can flag what was promised and not delivered
- Email the dashboard rather than opening a browser, so it arrives before the CEO is at a desk
- Add an end-of-day version that reviews what actually happened against the morning brief
What It Cannot Do
It works from the data it is given. It does not know that the 3pm meeting is really about a relationship problem, or that a particular sender always exaggerates urgency. Those judgements stay human, and the brief should sharpen the CEO’s attention rather than replace it.
It will also occasionally get a priority wrong. Treat the first fortnight as calibration — when it misjudges something, adjust the system prompt in part3_analyse.py. That file is where the agent’s judgement lives, and small edits there change the output substantially.
Build Agents Like This Across Your Organisation
Our four-day Building AI Agents with Claude programme covers tool use, connecting to real systems, guardrails and evaluation — delivered across UAE, KSA and MENA. Delegates bring a real process and leave with a working prototype.
View the Programme