Announcing CIMD support for MCP Client registration
Learn more

How to Build a Meeting Prep Agent with Google Calendar, HubSpot, Gmail, and Slack

Saif Ali Shaik
Founding Developer Advocate

TL;DR

  • The agent scans Google Calendar for external meetings in the next 48 hours, then for each one pulls the HubSpot contact record, recent Gmail threads with that person, and your team's internal Slack channel for their company. Claude turns the lot into a four-section brief that gets printed and posted to Slack.
  • Four connectors expose 634 tools between them, and HubSpot alone accounts for 457. The agent calls eight. None of the 634 ever enters a model context window, because the LLM in this pipeline writes prose; it does not choose tools.
  • The brief fuses a CRM record, your private email history with a customer, and your colleagues' internal discussion about them into one document. Every read is bounded by what the authorizing person can already see, and that scope boundary is the only thing standing between a useful brief and a dossier.
  • Then the last step undoes it. The brief goes to one shared channel, and deliver_brief.py explicitly rejects Slack user IDs, so the agent cannot DM the person it briefed.
  • Two smaller fixes worth making before you run it: only the first external attendee is looked up, and the Gmail search uses from: only, so your own commitments in Sent mail never reach the brief.
  • Clone the meeting prep agent repo, create four connections, and have it running in about 20 minutes. The Meeting Prep Agent template is the version to fork.

Ten minutes before a customer call, everyone does the same thing.

Open the calendar invite. Copy the attendee's email. Paste it into HubSpot to remember who they are and what stage the deal is at. Switch to Gmail and search their name to find what was last agreed. Switch to Slack and scroll #acme-deal to see whether solutions engineering flagged anything. Assemble all of it into a working memory that survives exactly as long as the call.

Four tools, four context switches, ten minutes, repeated before every external meeting. And the output is not saved anywhere, so the next person who talks to that customer starts over.

The agent that does this is about 970 lines across seven files, and it reads cleanly end to end. What makes it worth studying is not the pipeline. It is that this is the most data-sensitive agent in the template library, and its safety comes from two structural decisions rather than from anything in the prompt.

What Is Actually in the Brief

Read the four sections the prompt enforces and think about what each one contains.

Quick Facts holds the CRM record: role, company, deal linkage, whether this is an existing or cold contact. Email History summarizes your private correspondence with an external party. Slack Context summarizes your colleagues' internal discussion about that customer, which is where people say things like "their IdP is Okta, integration is about three days" and "they pushed back hard on the two-year term."

Talking Points then fuses all three.

That document is genuinely useful and it is also, assembled differently, an intelligence file. The internal Slack section in particular contains things nobody has said to the customer and nobody intends to. It exists in a channel because channels have membership. The moment an agent can read it, the membership boundary is only as good as the agent's identity.

So the interesting question about this agent is not "does it work." It is "whose eyes is it borrowing."

The Identity Boundary Is the Security Control

Four connectors. One identifier. Every call passes it:

resp = client.actions.execute_tool( tool_name="slack_fetch_conversation_history", identifier=USER_ID, connected_account_id=account_id, tool_input={"channel": match["id"], "limit": 20}, )

That parameter is doing the security work. slack_list_channels returns the channels this person can see, so a private channel they are not in cannot be matched and cannot be read. gmail_fetch_mails searches their mailbox, not the company's. HubSpot returns records their CRM permissions allow. Google Calendar returns their calendar.

Now imagine the shortcut. One service account, org-wide scopes, identifier set to a constant. The pipeline logic does not change by a single line and the agent still produces a brief. But it has quietly become a system that can assemble a document containing any customer's CRM record, any employee's email history with them, and any private channel's internal discussion, then post it to a Slack channel. Run it for one rep and it looks identical. Run it for a rep who should not see the enterprise team's channel and the difference is the entire point.

What the user cannot see, the agent cannot see. In most agents that principle is a good default. Here it is the whole control, and there is nothing else in the code enforcing it. Delegated agent access and credential ownership patterns cover why that boundary belongs in the auth layer rather than in application logic.

The scopes reinforce it. The README's setup asks for calendar.readonly, gmail.readonly, HubSpot contacts and companies read access, and Slack read scopes plus chat:write. Exactly one write capability across four services, and it is the delivery step. HubSpot makes this easy to get right: the connector requires only the oauth scope and treats everything else as optional, so a read-only enrichment flow asks for crm.objects.contacts.read and crm.objects.companies.read and nothing more. Optional scopes exist precisely so an enterprise admin reviewing your consent screen sees two permissions instead of twenty.

Recommended Reading: Why admin accounts are the wrong pattern for AI agents, and how to implement least privilege for agent tool calls.

634 Tools Available, Eight Called, Zero in Context

Add the four catalogs together and the numbers are striking. HubSpot ships 457 tools. Slack ships 91. Gmail ships 56. Google Calendar ships 30. That is 634 tools reachable from four connections.

This agent calls eight:

googlecalendar_list_events upcoming meetings hubspot_contacts_search attendee by email hubspot_companies_search company by domain gmail_fetch_mails threads with the contact gmail_get_thread_by_id message snippets slack_list_channels find the company channel slack_fetch_conversation_history recent internal discussion slack_send_message deliver the brief

Handing 634 schemas to a model would put something on the order of 120,000 tokens of tool definitions in context before it did any work, and would ask it to choose one action from a space containing hubspot_blog_tags_batch_archive, hubspot_association_limits_batch_purge, and 455 other things this agent will never need.

But the more interesting number is zero, because none of the 634 reaches a model at all.

Claude is called exactly once, at the very end, and it receives a JSON blob and a prompt:

client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"], max_retries=0) context_json = json.dumps(context, indent=2, default=str) prompt = PROMPT_TEMPLATE.format(context_json=context_json)

The model is a writer, not a router. Every tool call was already made by deterministic Python before it saw anything. That has three consequences worth naming. Tool selection cannot go wrong because there is no selection. Parameters cannot be hallucinated because they are literals in source files. And the data flow is auditable by reading seven files, so you can state exactly what reaches the model and what does not.

When you do put a tool surface in front of a model, retrieving only the tools the current connected account is authorized to call is the mechanism that keeps that surface small. This agent takes the blunter route and gets the property for free. Both are valid. The choice is whether the work is genuinely deterministic, and gathering four fixed context sources before a meeting is about as deterministic as agent work gets.

Recommended Reading: Unified tool calling architecture across LangChain, CrewAI and MCP.

The Last Step Undoes the First Three

Everything above scopes reads to one person. Then the brief is delivered:

def deliver_brief(meeting_title, brief, contact_name, company_name, meeting_date): """Print the brief and post it to Slack.""" slack_channel_id = os.environ["SLACK_NOTIFICATION_CHANNEL_ID"] # Slack channel IDs start with C (public) or G (private). User IDs (U), DM # IDs (D), and bot/workflow IDs would silently route the brief to the wrong place. if not slack_channel_id.startswith(("C", "G")): raise ValueError( f"SLACK_NOTIFICATION_CHANNEL_ID must start with C or G (got {slack_channel_id!r})" )

One channel, configured once, for every brief. So a pipeline that carefully read only what one person is allowed to see ends by broadcasting the result to whoever is in that channel. If it is the rep's own private channel, fine. If it is a shared sales channel, every rep's customer email summaries and every deal's internal Slack chatter land in front of the whole team.

The validation makes it worse rather than better. Slack's slack_send_message accepts a user ID in the channel field, which is how you send a direct message. This code explicitly rejects U and D prefixes, so the agent is structurally incapable of DMing the person whose data it just assembled. The comment's reasoning is sound in isolation, since a mistyped ID should not silently route a brief somewhere unexpected. But foreclosing DMs to prevent misrouting trades a small risk for a larger one.

The fix is small: allow U and D, and default to DMing the meeting owner rather than posting to a channel. If a shared channel is genuinely what a team wants, make it opt-in per user rather than the only option.

Prerequisites

  • Python 3.11 or newer
  • A Scalekit account; the free tier is enough
  • An Anthropic API key
  • Ability to authorize apps in Google Calendar, Gmail, HubSpot, and Slack
  • scalekit-sdk-python >= 2.12.0, anthropic, python-dotenv, python-dateutil (see the Python SDK reference)

If you have not configured a connection before, start with the AgentKit quickstart.

How to Set Up Your Connectors in Scalekit

Step 1: Create Four Connections

Under AgentKit, then Connections, create one connection per service. The agent resolves connections by name, so the names in the dashboard have to match your .env:

Provider
Default connection name
Env var
Google Calendar
meeting-prep-google-calendar
CALENDAR_CONNECTION_NAME
HubSpot
meeting-prep-hubspot
HUBSPOT_CONNECTION_NAME
Gmail
meeting-prep-gmail
GMAIL_CONNECTION_NAME
Slack
meeting-prep-slack
SLACK_CONNECTION_NAME

Naming connections after the agent rather than the provider is a good habit worth copying. It survives the day you add a second Gmail connection for a different agent in the same environment, and each name resolves to its own connected account per user. Tool references live at Google Calendar, HubSpot, Gmail, and Slack.

Step 2: Know Which HubSpot App Type You Need

This trips people up more than the other three combined. HubSpot has three app shapes and only two work here.

App type
OAuth redirect
Scope format
Works with Scalekit
Public app
Supported
Modern (crm.objects.contacts.read)
Recommended
Private app
Not supported
Static API token only
No
Legacy app
Supported
Bare strings (contacts)
Yes, use bare strings

Private apps issue a static API token and have no OAuth redirect endpoint at all, so they cannot participate in a flow Scalekit manages. Create a Public app in the HubSpot developer dashboard, paste Scalekit's redirect URI under Auth settings, and copy the client ID and secret back.

One more scope detail: the scope set in your HubSpot app and the scope set in Scalekit's Permissions field have to match exactly, or the user gets an invalid_scope error when they authorize. Configure them in HubSpot first, then copy across.

Step 3: Google Consent Screen, Once for Two Connectors

Gmail and Google Calendar share a Google Cloud project, so they share a consent screen decision. Internal restricts authorization to your own Workspace organization, which means nobody on @gmail.com or at a customer domain can connect. External works for everyone but shows {env_name}.scalekit.dev on the consent screen until Google verifies your app, and switching to an organization-managed OAuth client does not bypass that.

Both connectors also need their API enabled separately in the Google Cloud Console Library. Creating the OAuth client does not do it, and an agent with perfect credentials will fail every call until you do. If you are weighing the MCP variants instead, see Gmail MCP vs the Gmail API and Google Calendar MCP vs the Calendar API.

Step 4: Slack Scopes

channels:read and groups:read to list channels, channels:history and groups:history to read them, and chat:write to post. The private-channel scopes are separate from the public ones, and asking for the wrong pair produces missing_scope at call time rather than at authorization time, which during debugging looks exactly like a channel with no matching messages. Slack MCP vs the Slack API covers what each surface exposes.

Setting Up Auth with Claude Code

claude plugin marketplace add scalekit-inc/claude-code-authstack && claude plugin install agent-auth@scalekit-auth-stack

Then prompt Claude Code:

Set up Scalekit auth for Google Calendar, HubSpot, Gmail, and Slack. Check all four connections at startup, collect every one that is not ACTIVE, and print all the authorization links together rather than failing on the first one.

That last instruction is the detail worth copying. Most agents check connectors one at a time and exit on the first failure, so a first-time user authorizes, re-runs, hits the second failure, authorizes again, and repeats four times. This one collects them:

def check_all_connectors(): """Check every connector up front. If any need auth, print all links and exit.""" missing = [] for label, connector_name in CONNECTORS: resp = _scalekit.actions.get_or_create_connected_account( connection_name=connector_name, identifier=_USER_ID, ) if resp.connected_account.status != "ACTIVE": link = _scalekit.actions.get_authorization_link( connection_name=connector_name, identifier=_USER_ID, ).link missing.append((label, link)) if missing: print("\nThe following connectors need authorization. Open each link, authorize, then re-run `python main.py`.\n") for label, link in missing: print(f" {label}:\n {link}\n") raise SystemExit(1)

Four links, one round trip. In production you would return these links from your own application rather than printing them, but the shape is right: gather every unmet requirement before asking the user for anything.

There is no token in this codebase. Refresh, expiry, and rotation are handled server-side, which matters across four providers with four different expiry behaviors. OAuth for AI agents and handling token refresh cover why that lifecycle belongs in infrastructure.

Step 1: Find External Meetings

The definition of "external" is one line, and it is the right one:

INTERNAL_DOMAIN = USER_ID.rsplit("@", 1)[-1].lower()

Anything not matching the authorizing user's own email domain counts as external. No configuration, no allowlist, and it derives from the identity that already had to be set.

The filter then applies four tests in sequence:

def _is_external_meeting(event): attendees = event.get("attendees", []) if not attendees: return False emails = [a.get("email", "") for a in attendees if a.get("email")] if not any(e.rsplit("@", 1)[-1].lower() != INTERNAL_DOMAIN for e in emails): return False # everyone is internal if any("noreply" in e or "calendar.google.com" in e for e in emails): return False # calendar bot, not a person start = event.get("start", {}) if "dateTime" not in start: return False # all-day event start_dt = datetime.fromisoformat(start["dateTime"]) now = datetime.now(timezone.utc) return now <= start_dt <= now + timedelta(hours=48)

Each rejection removes a real category of noise. Solo blocks have no attendees. All-hands meetings are all internal. Automated calendar notifications carry noreply addresses. All-day events have a date rather than a dateTime and are usually holidays or travel, not meetings worth briefing.

Pagination is handled properly, which is easy to skip and expensive to skip:

resp = client.actions.execute_tool( tool_name="googlecalendar_list_events", identifier=USER_ID, connected_account_id=account_id, tool_input=tool_input, ) data = resp.data or {} events.extend(data.get("events", [])) page_token = data.get("next_page_token") if not page_token: break

Note the response keys. googlecalendar_list_events returns events and next_page_token, both snake_case, matching the documented schema exactly. Tool inputs are snake_case too: time_min, time_max, max_results, page_token. This is worth checking on every connector, because Gmail's docs warn that its tools return camelCase fields like threadId while accepting snake_case inputs like thread_id, and this agent has to handle both conventions within one pipeline.

Step 2: Look Up the Contact in HubSpot

Two searches with a deliberate fallback between them:

contact_resp = client.actions.execute_tool( tool_name="hubspot_contacts_search", identifier=USER_ID, connected_account_id=account_id, tool_input={ "query": email, "properties": ["firstname", "lastname", "email", "company", "hs_object_id"], }, )

Requesting properties explicitly is the small thing that matters. HubSpot returns a default property set otherwise, and asking for five named fields means the brief's context payload stays small and predictable rather than carrying whatever the portal happens to have configured.

If no contact matches, the agent tries the company by email domain, and if that also misses it says so rather than guessing:

company = _company_by_domain(account_id, domain) if company: return {"source": "company", "company": company, "domain": domain} return {"source": "not_found", "domain": domain}

The source key travels into the prompt, which instructs Claude to write "Existing contact" or "Cold contact" based on it. That is the correct division of labor: Python decides what is true, the model decides how to say it. For the inverse design, where Claude picks the HubSpot tools itself, see automating HubSpot workflows with a Claude agent.

Recommended Reading: HubSpot tool calling works great until your second user, and HubSpot MCP vs the HubSpot API.

Step 3: Pull Gmail Threads

resp = client.actions.execute_tool( tool_name="gmail_fetch_mails", identifier=USER_ID, connected_account_id=account_id, tool_input={ "query": f"from:{email} OR from:{domain}", "max_results": 10, }, )

The domain fallback is smart. If Jane has never emailed you but her colleague has, from:acme.com still surfaces the account relationship.

The direction is not. This searches received mail only. Everything you sent lives in your Sent folder and never matches, which means the brief cannot see your own commitments. The repo's own sample output makes the point: it shows "Open commitment: send revised quote by Friday" as an Email History bullet, and a promise you made is a message you sent. Change the query to (from:{email} OR to:{email}) and that line becomes reachable.

Thread expansion then costs one call per unique thread:

thread_resp = client.actions.execute_tool( tool_name="gmail_get_thread_by_id", identifier=USER_ID, connected_account_id=account_id, tool_input={"thread_id": thread_id}, ) thread_messages = (thread_resp.data or {}).get("messages", [])

Deduplicating by threadId first is what keeps that bounded, since ten messages from one busy thread would otherwise cost ten identical fetches. And note the casing: threadId came out of the response, thread_id goes into the next call. That is Gmail's documented convention, and it is the kind of thing that produces a confusing empty result rather than an error when you get it backwards.

Step 4: Find the Company Slack Channel

This is the weakest link in the pipeline and worth understanding before you run it on a real workspace.

def _normalize(name): return name.lower().replace(" ", "").replace("-", "").replace("_", "") match = next( (ch for ch in channels if needle in _normalize(ch.get("name", ""))), None, )

Substring matching on a normalized channel name, first match wins. For "Acme" against #acme-deal that is exactly right. For a company called Box, the needle box matches #sandbox, #inbox-zero, and #boxing-club, and whichever appears first in the channel list gets read and summarized into the brief.

The company name itself can make this worse, because it falls back to the email domain:

def _derive_company_name(contact, domain): """Pick the cleanest company label we have for this attendee.""" return ( contact.get("company") or contact.get("company_name") or domain.split(".")[0].capitalize() )

For jane@mail.acme.co.uk with no HubSpot record, that yields "Mail", and the Slack search then goes looking for a channel containing "mail". The consequence is not an error. It is a brief with a confidently-written Slack Context section summarizing a channel that has nothing to do with the meeting.

Two changes make it safe. Require the match to be a word boundary or a prefix rather than any substring, and skip the Slack step entirely when the company name came from the domain fallback rather than from HubSpot. A missing Slack section is honest; a wrong one is worse than nothing.

The message cleanup on the way out is a nice touch worth keeping:

def _strip_user_mentions(text): return re.sub(r"<@U[A-Z0-9]+>", "@user", text or "")

Slack embeds raw user IDs in message text. Left in, they waste tokens and mean nothing to the model. Replaced, they read as what they are.

Step 5: Generate and Deliver

The prompt is worth reading in full in the repo, but two design choices carry it. Section headers are specified exactly, so the output shape is stable across runs and across meetings. And every section has an explicit empty case: "No emails on file", "No Slack channel found", "No HubSpot record". Telling a model what to write when data is missing is the cheapest defense against a model inventing something to fill the space.

Overload handling is explicit rather than delegated:

MODEL = "claude-sonnet-4-6" MAX_TOKENS = 1500 # ~350 words of output, well under the cap # Wait schedule (seconds) between attempts when Anthropic returns 529 overloaded. # 3 retries total, exponential backoff. OVERLOAD_BACKOFFS = [10, 20, 40] except APIStatusError as e: if e.status_code != 529 or attempt == len(OVERLOAD_BACKOFFS): raise wait = OVERLOAD_BACKOFFS[attempt]

Passing max_retries=0 to the Anthropic client disables the SDK's own retry so the two schemes do not compound. Retrying only on 529 and re-raising everything else is the correct narrowness: a 400 means the request is wrong and retrying it three times just delays the error by seventy seconds.

How to Run It

python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt cp .env.example .env python main.py
[2026-05-21 18:02:11 UTC] Checking Scalekit connector status... [2026-05-21 18:02:13 UTC] Fetching external meetings in the next 48 hours... [2026-05-21 18:02:13 UTC] Found 1 external meeting(s). [2026-05-21 18:02:13 UTC] START Acme - Discovery [2026-05-21 18:02:13 UTC] HubSpot lookup for jane@acme.com... [2026-05-21 18:02:14 UTC] Gmail thread search... [2026-05-21 18:02:16 UTC] Slack channel search for 'Acme Inc'... [2026-05-21 18:02:18 UTC] Generating brief... [2026-05-21 18:02:23 UTC] Delivering brief... [2026-05-21 18:02:24 UTC] DONE Acme - Discovery [2026-05-21 18:02:24 UTC] All meetings processed.

There is no scheduler, no database, and no background process. Point cron at it, or run it whenever you want a fresh brief.

What to Change Before You Run This for a Team

Deliver to the person, not the room. Covered above, and it is the first change to make. Allow U and D prefixes and DM the meeting owner by default.

Brief on every external attendee. process_meeting takes meeting["external_attendees"][0] and ignores the rest, so a call with three people from the customer produces context on one of them. Looping the HubSpot and Gmail lookups over all external attendees is a small change with a large effect on brief quality.

Tighten Slack matching, or skip it. Word-boundary matching instead of substring, and no Slack lookup at all when the company name came from the domain fallback.

Search both directions in Gmail. (from:X OR to:X) rather than from: alone.

Deduplicate across runs. The README is upfront that repeated runs re-brief the same meetings. A state file keyed on calendar event ID is enough, and it also prevents the same brief landing in Slack twice when a cron overlaps a manual run.

Consolidate the clients. Each of the six modules constructs its own ScalekitClient at import time, and each lookup_* function calls _ensure_active_account() on every invocation. Across N meetings that is 4N redundant get_or_create_connected_account calls on top of the four already made at startup. One shared client and one auth check per run removes them.

Wire the audit question. Given what this agent reads, "which identity called which tool, against which connector, and when" is a question someone will ask. Tool call logs and auth logs carry it, and the launch checklist is the short version of what to confirm first.

Recommended Reading: Audit trails for agent auth in B2B SaaS, and how to revoke an employee's agent access when they leave.

Extending the Agent

Add a connector. Create the connection, copy an existing lookup_*.py, and wire it into process_meeting(). The shape never changes: get_or_create_connected_account, then execute_tool, then read resp.data. Notion for the account plan, Gong or Granola for the last call recording, Jira for open support escalations. The connector catalog has the tool names.

Swap the CRM. Salesforce and Attio follow the same search-then-read pattern. The deal intelligence agent and the sales call prep agent are the nearest neighbours if you want to see that variant.

Give the model tools. If you want the agent to decide what context it needs rather than always gathering all four sources, that is when a scoped tool surface earns its place. Retrieve the tools the current connected account is authorized to call, hand the model those, and keep the 457 HubSpot tools out of context.

Run it for a team. Replace the single SCALEKIT_USER_ID with each person's real identifier and loop. Every read then rescopes automatically to that person's access, which is the property the whole design rests on. Access control for multi-tenant AI agents and single vs multi-tenant tool calling cover the isolation model once several people are connected.

The full code is on GitHub. Clone the repo, create four connections, and have it running in under 20 minutes. Browse the catalog at Scalekit connectors, or start from another pattern in the agent template library.

If you get stuck, the Scalekit community Slack is the fastest place to get an answer.

FAQ

Does the LLM decide which tools to call?

No. All eight tool calls are made by Python before Claude is invoked. The model receives one JSON context blob and one prompt, and its only job is writing the brief. That is why 634 available tools never cost a token: nothing puts them in front of a model.

Why does the brief go to a shared Slack channel?

Because SLACK_NOTIFICATION_CHANNEL_ID is a single value and deliver_brief.py rejects anything that does not start with C or G. Given that the brief contains private email summaries and internal Slack discussion, DMing the meeting owner is the better default. slack_send_message accepts a user ID in the channel field, so allowing U and D is the whole change.

Can I use a HubSpot Private app?

No. Private apps issue a static API token and have no OAuth redirect endpoint, and Scalekit's HubSpot connector requires an OAuth flow. Create a Public app. If you already have a legacy developer-account app, that works too, but it uses the older bare scope strings like contacts rather than crm.objects.contacts.read, and the scope set has to match exactly on both sides or authorization fails with invalid_scope.

What does the agent see if I run it for someone else?

Whatever that person can see, and nothing more. identifier scopes every call to their connected accounts, so Slack returns their channels, Gmail searches their mailbox, and HubSpot honors their CRM permissions. That is the design's main safety property, and it disappears the moment you point the agent at a shared service account.

Does Scalekit refresh tokens across all four connectors?

Yes, and there is no token in the agent's code. Each provider expires and rotates differently, and Gmail additionally revokes mail-scope refresh tokens whenever a user changes their password. Revocation is not something refresh can fix, which is why check_all_connectors() runs first and surfaces an authorization link rather than failing partway through a brief.

No items found.
Agent
Auth Quickstart
On this page
Share this article
Agent
Auth Quickstart

Acquire enterprise customers with
zero upfront cost.

Every feature unlocked. No hidden fees.