Read ../config/user.json (resolves to ~/executive-assistant-skills/config/user.json).
Extract and use throughout:
name, full_name — user's nameprimary_email, work_email — Gmail accounts to checkwhatsapp — WhatsApp number for deliverytimezone — IANA timezone (e.g. America/Argentina/Buenos_Aires)slack_username — Slack DM targetworkspace — absolute path to OpenClaw workspace (e.g. ~/.openclaw/workspace)Do not proceed until you have these values.
Read ../config/DEBUG_LOGGING.md for the full convention. Use python3 {user.workspace}/scripts/skill_log.py meeting-prep <level> "<message>" ['<details>'] at every key step. Log BEFORE and AFTER every external call (gog, mcporter, Granola, web search). On any error, log the full command and stderr before continuing.
Timezone note: Use explicit ART-bounded ISO8601 timestamps for calendar queries, NOT --date. Example: gog calendar list primary --account <email> --from "2026-03-03T00:00:00-03:00" --to "2026-03-04T00:00:00-03:00" --json. The --date flag uses UTC boundaries which misaligns with ART.
Full research brief (email context, Granola, LinkedIn, company research) — see below.
Lighter brief — no LinkedIn/company research needed, but still include:
These are external meetings — give them full briefs. Don't skip recurring meetings just because they're familiar.
gog calendar fails for one account: continue with the other account, note "⚠️ [account] calendar unavailable" in output.Title, local time ({user.timezone}), attendees.
RSVP status (MANDATORY): For each attendee, check responseStatus from the calendar event:
accepted → no flag neededneedsAction → flag as "⚠️ hasn't responded"declined → flag as "❌ declined"tentative → flag as "❓ tentative"If ANY non-organizer external attendee has NOT accepted (needsAction, tentative, or declined), add a visible line in the brief:
⚠️ RSVP:
hasn't accepted yet
This is informational — it doesn't mean they won't join, but it's useful to know ahead of time, especially for first calls or important meetings.
Search Gmail both accounts for exchanges with attendees. For EACH attendee, search using these strategies in order:
from:<email> OR to:<email>
"firstname lastname"
"intro firstname" (catches informal intro subjects)"firstname companyname"
The attendee's email from the calendar invite is the most reliable identifier — always start there.
Intro discovery (after general email search):
5. Search for intro emails involving the attendee: subject:intro <email>, subject:intro <firstname>, subject:introduction <email>
6. Also check threads where a third party CC'd/introduced the attendee
Recent email context (after intro discovery): 7. Pull the most recent email threads with this attendee (by email address) to surface any recent updates, asks, or context leading into today's call
Historical fallback (if no results from 90-day search):
8. Run a broader search with NO date filter: from:<email> OR to:<email> — this catches long-standing relationships where the last email was months/years ago. If older threads exist, this is NOT a first call — note the relationship history.
Search by ATTENDEE, not by meeting title. The same recurring meeting may have different titles week to week. Always search by the attendee's name or email to find all past meetings with them.
# Primary: search by attendee name
mcporter call granola.query_granola_meetings query="meetings with [attendee full name]"
# Fallback: search by company if attendee name yields no results
mcporter call granola.query_granola_meetings query="meetings with [company name]"
Cross-check with list_meetings: If the query results seem stale (oldest match is weeks old but you expect more recent), also run list_meetings for last_week or this_week and scan the participant lists for the attendee's email or name. This catches meetings where the title doesn't mention the attendee or company.
[[N]](url)
mcporter auth granola --reset and retry once. If still failing, note "⚠️ Granola unavailable" and continue without it."[attendee name] [company] LinkedIn"
"[company] recent news"
"[company] funding crunchbase" (if startup/VC relevant)Read {user.workspace}/style/MEETING_PREP_RULES.md for additional research steps.
Send via WhatsApp ({user.whatsapp}) AND Slack (DM to {user.slack_username}). One message per meeting, chronological order, is mandatory.
Also send to Chief of Staff: After sending all meeting briefs, upload the markdown brief file ({user.workspace}/state/meeting-prep-YYYY-MM-DD.md) to {user.chief_of_staff.name}'s Slack DM. Use the Slack API files.upload (or files.uploadV2):
curl -s -F file=@{user.workspace}/state/meeting-prep-YYYY-MM-DD.md \
-F channels={user.chief_of_staff.slack_dm_channel} \
-F title="Meeting Prep — <date>" \
-F initial_comment="📋 *Meeting Prep — <day>*" \
-H "Authorization: Bearer <bot_token>" \
https://slack.com/api/files.upload
Never collapse into a single summary block. The user expects one standalone message per meeting. Send each meeting brief as a separate message to BOTH WhatsApp and Slack. If one channel fails, still deliver to the other.
Start with a short intro: "📋 MEETING PREP —
Then one message per meeting in this format — use bold subsections and blank lines between each section for readability:
*<number>. <Name/Company> — <local time>*
*Who:* <Role>, <Company> (<location>). <What the company does, 1 sentence>. <Funding/stage if relevant>.
*Context:* <First call vs follow-up>. <If first call: who intro'd + when (date); if unavailable: "No intro trail found in email">.
*Email history:* <Key email context — include important commercial/decision triggers when present (pricing, scope, deliverables, urgency, budget, decision-maker request)>.
*Granola:* <Richer recap: key decisions, action items, owners, unresolved questions, and why a follow-up was needed. If no attendee notes, use company-level notes and label it. Or "No previous meetings found in Granola">.
*Why this meeting now:* <One sentence grounded in prior action items and/or current email trigger>.
*Focus areas:* <ONLY items derived from prior action items and current email trigger — not generic strategy prompts>.
*Links:* <LinkedIn, company site, Crunchbase>
Each section on its own paragraph (blank line before each bold label). Keep it concise but well-structured — readability over density.
If a meeting already happened, prefix with ✅ and keep brief. If there's a schedule conflict, flag with ⚠️.
Save the full detailed brief to {user.workspace}/state/meeting-prep-YYYY-MM-DD.md and also send it as a file attachment via WhatsApp.
This section is NON-OPTIONAL. Cron creation MUST happen for every run with meetings. If you run out of context or time before completing this section, the entire run is a FAILURE.
Execution order: Create ALL crons IMMEDIATELY after saving the brief file — BEFORE the assertions step. Do not defer cron creation to "after everything else."
Log: python3 {user.workspace}/scripts/skill_log.py meeting-prep INFO "Starting cron creation for N meetings"
After generating all briefs, create a one-shot cron job for EACH meeting that fires 5 minutes before start time. The cron job should:
{user.workspace}/state/meeting-prep-YYYY-MM-DD.md
Hard formatting contract (no exceptions):
{user.workspace}/state/meeting-prep-YYYY-MM-DD.md for that meeting block.⏰ *5 min reminder* prefix.Use openclaw cron add with --at set to 5 min before meeting time, --delete-after-run, --no-deliver, --channel whatsapp, and --to {user.whatsapp}. The --no-deliver flag prevents the announce mechanism from sending a separate message — the task sends WhatsApp directly.
Hard requirement: after creating jobs, run openclaw cron list and verify the expected number of pre-meeting- jobs for today. If count is lower than expected, immediately retry creation and report failure explicitly.
Log each created cron: python3 {user.workspace}/scripts/skill_log.py meeting-prep INFO "Created pre-meeting cron" '{"meeting": "<name>", "fires_at": "<time>"}'
After generating all briefs, create a one-shot cron job for EACH meeting that fires 10 minutes after the meeting END time. The cron task should reference the action-items-todoist skill:
Task: "Read and follow ~/executive-assistant-skills/action-items-todoist/SKILL.md. Process ONLY the meeting titled '
Use openclaw cron add with --at set to 10 min after meeting end time, --delete-after-run, --session isolated, --timeout-seconds 1200, --no-deliver, --channel whatsapp, and --to {user.whatsapp}. Name them post-meeting-<short-name>.
Hard requirement: after creating jobs, run openclaw cron list and verify the expected number of post-meeting- jobs for today. If count is lower than expected, immediately retry creation and report failure explicitly.
Log each created cron: python3 {user.workspace}/scripts/skill_log.py meeting-prep INFO "Created post-meeting cron" '{"meeting": "<name>", "fires_at": "<time>"}'
Log final count: python3 {user.workspace}/scripts/skill_log.py meeting-prep INFO "Cron creation complete" '{"pre_meeting": N, "post_meeting": M, "expected": E}'
If cron count doesn't match expected: Log ERROR and send WhatsApp alert: "⚠️ Meeting prep: only created X/Y pre-meeting and A/B post-meeting crons. Some reminders/action-items may be missing."
After processing, the cron MUST append the meeting title to {user.workspace}/state/processed-meetings-YYYY-MM-DD.json (array of meeting titles already processed). This lets the end-of-day catch-all skip them.
Before creating ANY Todoist task, the cron MUST:
{user.workspace}/state/processed-meetings-YYYY-MM-DD.json — if this meeting is already listed, SKIP entirely (another cron already handled it)This prevents the scenario where a post-meeting cron and the daily end-of-day cron both process the same meeting and create duplicate tasks.
pre-meeting- jobs and post-meeting- jobs created for today must each equal number of meetings with attendees (external + internal).After sending all meeting messages and creating all one-shot jobs, run:
python3 {user.workspace}/scripts/meeting_prep_assertions.py \
--date YYYY-MM-DD \
--brief-file {user.workspace}/state/meeting-prep-YYYY-MM-DD.md