WorkflowsMCP

Sales call prep brief: Calendar → HubSpot + Notion → Slack

Match calendar meetings to HubSpot deals and Notion notes, DM the rep a brief.

@workflowsmcpVerified salescrmai-summaryautomation

Most reps don't open a customer's CRM record until they're already on the call. This polls Google Calendar for meetings with external attendees, matches each one to its HubSpot contact and deals, pulls the account's Notion notes, and DMs the rep a brief before the call starts. HubSpot's own Breeze Assistant can summarize a meeting, but only once you remember to click Prepare, and only from HubSpot's own records — it never sees Notion notes. This covers the rep's own external calls; internal team meetings are a separately scoped brief.

How it flows

  1. 01

    Poll Calendar for calls starting soon

    Every 5–10 minutes, list events on the primary calendar for the next 30 minutes, singleEvents=true and orderBy=startTime so a recurring meeting resolves to the one instance actually starting.

  2. 02

    Dedupe by event id

    Track processed event ids locally — the read-only scope means nothing can be written back onto the Calendar event to mark it handled — with entries expiring after a day.

  3. 03

    External attendees picked out by email domain

    attendees[] has no field for "outside the company" — compare each attendee's email domain against the rep's own, found on the attendees[] entry where self=true (not organizer: an externally-scheduled call, e.g. one the prospect books through a scheduling link, shows the prospect as organizer, not the rep, so excluding "organizer" would wrongly exclude the prospect from being flagged external).

  4. 04

    Each external email matched to a HubSpot contact and its deals

    Contact search by email, then associations and a batch read for deal stage, amount, and close date; queued one at a time under the search endpoint's 5-requests/second cap. No match falls back to a company-domain search.

  5. 05

    Company name checked against Notion account notes

    Title search using the matched HubSpot company name, or the attendee's email domain when no company matched.

  6. 06

    Brief assembled

    Meeting time, the external attendee's name and company, the two most relevant deals plus a total open-deal count, and the Notion notes found.

  7. 07

    Rep's Slack id resolved and the brief DMed

    users.lookupByEmail on the self-flagged attendee's email (not the organizer field, which can be the external contact's email when they sent the calendar invite), then chat.postMessage straight to that user id — no separate DM-open call.

  8. 08

    Silence never wins

    No CRM match still sends the brief, noting the contact isn't in HubSpot yet; no Notion notes still sends, noting none were found — either way the rep gets a message instead of nothing.

Set up each app

Work through these in order — later apps usually need a token or an id from an earlier one.

Google Calendar

Trigger — spots calls with attendees from outside the company

  1. 01

    Enable the Calendar API and configure OAuth consent

    Google Cloud Console → APIs & Services → Library → search "Google Calendar API" → Enable. Then APIs & Services → OAuth consent screen → set App name and User type (Internal only works inside a Workspace domain; External + Testing mode is enough for a team running this on itself).

  2. 02

    Create a Web application OAuth client

    Credentials → Create Credentials → OAuth client ID → Web application, not Desktop app — the Desktop quickstart only covers a one-off interactive login, not a refresh token an unattended poller can renew silently. Build the authorization URL with access_type=offline, or the access token has no way to renew once it expires.

  3. 03

    Scope to read-only events and poll a 30-minute window

    Use calendar.events.readonly — Google's own scope list describes it as "View events on all your calendars", narrower than the calendar.readonly scope most tutorials default to. Calendar has no webhook for "a meeting starts soon" — reminders.overrides only ever fires to email or a popup — so poll every 5–10 minutes with a 30-minute window instead. Both timeMin and timeMax are exclusive bounds and need a timezone offset; singleEvents=true has to come before orderBy=startTime is accepted, or a recurring meeting comes back as one rule instead of the instance actually starting soon.

    GET /calendar/v3/calendars/primary/events
    GET https://www.googleapis.com/calendar/v3/calendars/primary/events
      ?timeMin=2026-08-08T14:30:00-04:00
      &timeMax=2026-08-08T15:00:00-04:00
      &singleEvents=true
      &orderBy=startTime
      &maxResults=10
    Authorization: Bearer {access_token}
  4. 04

    The scope value

    The full scope URI to request in the OAuth consent flow — the scope checkbox or `scope=` parameter needs this exact string, not the shorthand name.

    The scope value
    https://www.googleapis.com/auth/calendar.events.readonly

HubSpot

Matches the external attendee to a contact and its open deals

  1. 01

    Name the native shortcut before you build around it

    HubSpot's own Breeze Assistant already writes a call brief: open the meeting in HubSpot's calendar view and click Prepare, and it pulls the contact's tickets, calls, emails, and CRM activity into a summary — free on every subscription. Two gaps this workflow closes: Prepare only runs when a rep remembers to click it on that specific meeting, and it only ever reads HubSpot's own objects — it has no way to see the account notes your team keeps in Notion.

  2. 02

    Create a private app token

    HubSpot → Settings → Integrations → Private Apps (also reachable via "Legacy apps" — HubSpot renamed the section but kept the old path working). Scopes: crm.objects.contacts.read, crm.objects.deals.read, and crm.objects.companies.read for the fallback lookup below. Read-only — this workflow never writes to HubSpot.

  3. 03

    Search contacts by email

    POST /crm/v3/objects/contacts/search with an EQ filter on email, using the email of each attendee the Calendar step flagged as external — email isn't an enumeration property, so matching is case-insensitive and there's no need to lowercase the address first. One request per external attendee; the search endpoint caps at 5 requests/second per account, tighter than the general private-app limit, so queue attendee lookups rather than firing them concurrently when a call has several outside guests.

    POST contacts/search
    curl -X POST "https://api.hubapi.com/crm/v3/objects/contacts/search" \
      -H "Authorization: Bearer $HUBSPOT_PRIVATE_APP_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "filterGroups": [{
          "filters": [{ "propertyName": "email", "operator": "EQ", "value": "lee@northwind.co" }]
        }],
        "properties": ["email", "firstname", "lastname", "company"],
        "limit": 1
      }'
  4. 04

    Pull the deals, or fall back to a company match

    Take the matched contact id into GET /crm/v4/objects/contacts/{id}/associations/deals for its deal ids, then POST /crm/v3/objects/deals/batch/read for dealname, dealstage, amount, and closedate on each. Sort what comes back by closedate and keep the two soonest-closing deals for the brief, with a line noting how many others are open — a full deal list blows the brief's length on any account with real history. If the email search in the previous step came back empty (common on a brand-new contact who isn't in the CRM yet), fall back to POST /crm/v3/objects/companies/search filtering domain EQ on the attendee's email domain, and say in the brief that the contact isn't in HubSpot yet rather than leaving that section blank. (URLs here use /crm/v4/; HubSpot has moved to date-based versions like /crm/objects/2026-03/contacts, and v4 is scheduled to stop being supported on 2027-03-30 — swap in the dated path before then.)

    associations + batch/read
    curl -X GET "https://api.hubapi.com/crm/v4/objects/contacts/{contactId}/associations/deals" \
      -H "Authorization: Bearer $HUBSPOT_PRIVATE_APP_TOKEN"
    
    curl -X POST "https://api.hubapi.com/crm/v3/objects/deals/batch/read" \
      -H "Authorization: Bearer $HUBSPOT_PRIVATE_APP_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "inputs": [{ "id": "8642091" }],
        "properties": ["dealname", "dealstage", "amount", "closedate"]
      }'

Notion

Surfaces the account's own notes, matched by company name

  1. 01

    Create the connection at the current URL

    The old notion.so/my-integrations now redirects — create it at app.notion.com/developers/connections instead (Developer portal → Connections → New connection; needs workspace-owner permission). Under Capabilities, check only Read content; this workflow never writes to Notion.

  2. 02

    Add the connection to every account-notes page

    A new connection starts with access to nothing. Open each customer or account page or database to be matched against → "•••" → Add connections → select it by name. There's no workspace-wide grant — repeat per page or database.

  3. 03

    Search by company name, not by keyword

    POST /v1/search matches only page and data-source titles, as a plain substring with no fuzzy tolerance — it does not search page body content. Use the company name matched from HubSpot as the query, falling back to the attendee's email domain, stripped of its suffix, when no HubSpot company matched. This depends on your team naming account pages consistently with the CRM company name — a page titled with a regional suffix the CRM doesn't carry will not turn up.

    POST https://api.notion.com/v1/search · Notion-Version: 2026-03-11
    {
      "query": "Northwind",
      "filter": { "value": "page", "property": "object" },
      "sort": { "direction": "descending", "timestamp": "last_edited_time" },
      "page_size": 5
    }

Slack

DMs the rep — never the external contact — with the finished brief

  1. 01

    Create the app with two Bot Token Scopes

    api.slack.com/apps → Create New App → From scratch → OAuth & Permissions → Bot Token Scopes: chat:write and users:read.email. Install to Workspace and copy the xoxb- token. chat.postMessage's channel parameter accepts a Slack user id directly and opens the DM itself, so there's no im:write scope or conversations.open call to add.

  2. 02

    Resolve the rep's own email, not every attendee's

    This only DMs the rep running the call, so call users.lookupByEmail once, on the email of the attendees[] entry where self=true — that is always the rep, because this polls the rep's own primary calendar. Do not use the organizer field for this: organizer reflects who created the event, and on a call the prospect scheduled through a booking link, organizer is the prospect's email, not the rep's. Skip the multi-attendee resolution a broader meeting-brief workflow would need, since the brief never goes to the external contact. Slack answers every call, success or failure, with HTTP 200; check the response body's ok field rather than the status code to see whether the DM actually sent.

    POST https://slack.com/api/chat.postMessage
    {
      "channel": "U024BE7LH",
      "text": "Call prep: Northwind — 3pm today",
      "blocks": [
        { "type": "section", "text": { "type": "mrkdwn",
          "text": "*Northwind* — call in 15 min\n\n*Deal:* Northwind Expansion · Decision Maker Bought-In · $42,000 · closes 2026-09-15\n\n*Account notes:* <https://notion.so/...|Northwind account page>" } }
      ]
    }