Build a Claude Executive PA Agent That Reads Your Outlook and Briefs You Daily
AI AgentsAugust 202614 min read

Build a Claude Executive PA Agent That Reads Your Outlook and Briefs You Daily

By CTH Editorial · August 2026 · Corporate Training Hub L&D Desk

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

Read
This agent requests Mail.Read and Calendars.Read only. It cannot send, delete or modify anything. Before deploying this on an executive’s mailbox, involve your IT team — in most organisations creating an app registration requires administrator consent, and doing it quietly is a poor idea regardless.

Step 1: Register the App in Microsoft Entra

This gives your script an identity Microsoft recognises. It does not give it your password.

1
Go to entra.microsoft.com and sign in with an admin account.
2
Navigate to Applications → App registrations → New registration.
3
Name it Executive PA Agent. Under supported account types choose Accounts in this organizational directory only. Leave the redirect URI blank.
4
On the overview page, copy the Application (client) ID and Directory (tenant) ID. You need both shortly.
5
Go to API permissions → Add a permission → Microsoft Graph → Delegated permissions. Add Mail.Read and Calendars.Read, then click Grant admin consent.
6
Go to Authentication, scroll to Advanced settings, and set Allow public client flows to Yes. This enables sign-in without storing a secret.

Step 2: Install the Dependencies

Terminal
pip install msal requests anthropic

Step 3: Set Your Credentials

These go in environment variables, never in the script itself. Windows:

Windows — Command Prompt
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:

Mac / Linux — Terminal
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.

part1_auth.py
"""
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:

Terminal
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.

part2_fetch.py
"""
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.

part3_analyse.py
"""
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.

part4_dashboard.py
"""
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'>&rarr; {_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> &mdash; {_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 &mdash; {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&rsquo;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')} &middot; Drafted by Claude &mdash; 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.

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:

Terminal
python runner.py

Step 9: Schedule It

Windows — run Command Prompt as administrator:

Windows Task Scheduler
schtasks /create /tn "Daily Brief" /tr "python C:\PA\runner.py" /sc daily /st 07:00

Mac or Linux:

cron
# 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

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
← Back to All Insights

Ready to Develop Your Team?

Talk to our training consultants about your specific needs. We’ll design a programme that delivers real business impact — not just a training day.

Contact Us