
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.
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."
Four connectors. One identifier. Every call passes it:
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.
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:
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:
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.
Everything above scopes reads to one person. Then the brief is delivered:
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.
If you have not configured a connection before, start with the AgentKit quickstart.
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:
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.
This trips people up more than the other three combined. HubSpot has three app shapes and only two work here.
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.
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.
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.
Then prompt Claude Code:
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:
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.
The definition of "external" is one line, and it is the right one:
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:
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:
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.
Two searches with a deliberate fallback between them:
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:
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.
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:
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.
This is the weakest link in the pipeline and worth understanding before you run it on a real workspace.
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:
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:
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.
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:
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.
There is no scheduler, no database, and no background process. Point cron at it, or run it whenever you want a fresh brief.
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.
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.
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.
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.
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.
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.
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.