TL;DR
- A RevOps agent that pulls open pipeline from Salesforce and HubSpot, merges both into one stage-level view, calculates coverage against quota, flags at-risk stages from signals on the records themselves, drafts the commentary, posts it to Slack, and appends a snapshot row per stage to Google Sheets.
- Slack posts are gated on a content fingerprint over each stage's deal count, total value, and at-risk flag; the agent can poll continuously and stays silent until the pipeline actually moves. Google Sheets logging is append-only and runs every cycle regardless.
- Four connectors, four different OAuth models: Salesforce needs a packaged External Client App installed by an org admin, HubSpot rejects Private apps entirely, Slack ships two connector variants with incompatible parameter names, and Google Sheets access tokens expire every hour. All four are handled by Scalekit AgentKit behind one actions.execute_tool() call.
- The four connectors expose 598 tools between them. This agent calls nine. Clone the repo, configure your connections, and have it running in under 30 minutes.
- Start from the revenue forecast commentary agent template if you want the whole thing prebuilt.
Why Forecast Commentary Is Still Assembled by Hand
The forecast number is not the hard part. The narrative around it is.
Consider a RevOps analyst at a company mid-migration: legacy enterprise deals still live in Salesforce, the new inbound motion runs in HubSpot. Every Monday before the forecast call, that analyst exports open Opportunities from one system, exports open Deals from the other, reconciles two different stage vocabularies into one view, divides total open value by the quota number, and then writes three paragraphs explaining which segments look thin and why.
Every step is mechanical. None of it requires judgment until the last paragraph. And the analysis is only as fresh as the last export, which means the commentary presented on Monday describes a pipeline that stopped being true on Friday.
The parts a machine should own are exactly the parts that consume the hour: pulling, merging, dividing, flagging. This agent takes those.
How the Forecast Agent Works: A Deterministic Pipeline
This is not an autonomous reasoning agent. There is no planning loop, no tool selection at runtime, no persistent memory of prior conversations. It is a fixed four-step pipeline that runs, writes, and exits.
That is deliberate. A forecast number that changes because the model picked a different tool this week is not a forecast number. Every stage of the pipeline is deterministic and testable, and the only LLM involvement is an optional pass that rewrites the already-computed commentary more fluently without touching a single figure.
Each cycle runs the same four steps:
- Fetch. Open Opportunities from Salesforce via SOQL; open Deals from HubSpot, scoped to the stage IDs whose pipeline metadata reports isClosed: false.
- Calculate. Merge both into stage segments, compute the coverage ratio against a configured quota, and flag at-risk stages.
- Post. Draft commentary and send it to Slack, but only if the pipeline actually changed since the last post.
- Log. Append one snapshot row per stage to Google Sheets. This runs every cycle.
Steps 3 and 4 are decoupled on purpose, and the reason is the most interesting design decision in the agent. We will come back to it.
Where These Four Connectors Actually Fight You
Every tutorial about multi-CRM reporting shows the API calls and stops there. The calls are the easy part. Here is what you are actually signing up for when you connect these four services for real users.
Salesforce Needs a Packaged App and an Admin Who Will Install It
Salesforce is the heaviest setup of the four, and the cost is front-loaded before you write any code.
Distributing an OAuth integration to any customer org uses the External Client App (ECA) model, and that requires two Salesforce orgs before you begin: a Partner Business Org that acts as your Dev Hub, and a Developer Edition Org used solely to register a namespace. You link the namespace to the Dev Hub, create the ECA with id, api, and refresh_token scopes and PKCE enabled, then use the sf CLI to create a managed package, cut a version, and promote it to released. The output is an install URL you paste back into Scalekit.
Two constraints bite later. The OAuth scopes on the ECA must match the scopes you enter in Scalekit exactly; a mismatch fails authorization. And changing scopes after publish is not a settings edit: you retrieve the ECA metadata again, create and promote a new package version, update the install URL, and every existing user reinstalls. Decide your scope set before you ship.
Then there is the human step. A Salesforce admin has to install the package in their org once before anyone in that org can connect. Non-admin users who open the authorization link get an install screen with a copy-link option to forward to their admin. Plan for that round trip in your onboarding flow, because it is a real delay measured in days, not seconds.
Full setup walkthrough with screenshots is in the Salesforce connector docs.
HubSpot Rejects the App Type Most Teams Reach For First
HubSpot has three app shapes and only two of them work here.
Private apps issue a static API token and expose no OAuth redirect endpoint. They are the fastest thing to create in the HubSpot UI, which is exactly why teams reach for them, and they are incompatible with any delegated OAuth flow. Public apps are the supported path and use modern dotted scope names such as crm.objects.deals.read. Legacy developer-account apps still work but speak an older scope vocabulary of bare strings like contacts and automation, and they reject the dotted format outright.
That vocabulary split is where the time goes. Your HubSpot app's scope set and the Permissions field on the Scalekit connection have to match exactly, and a mismatch surfaces as invalid_scope at the moment the user authorizes, not at configuration time. Configure scopes in the HubSpot app first, then copy them across. HubSpot's connection requires only the oauth scope, which is added automatically, and supports up to 23 optional scopes on top; grant only what your tools need, because optional scopes keep the consent screen short and the admin review fast.
One more fork. Scalekit's catalog carries both a plain REST HUBSPOT connector and a HUBSPOTMCP variant, and they expose different toolsets. This agent is built against the plain REST connector's hubspot_deal_pipelines_list and hubspot_deals_search. If your workspace has the MCP variant active instead, the tool names in this code will not resolve. Check which one holds an ACTIVE connected account before assuming.
Recommended Reading: HubSpot MCP vs API: which one should your agent call?
Slack Ships Two Connectors With Incompatible Parameter Names
Same product, two connectors, and the difference is not cosmetic.
The plain Slack connector is OAuth 2.0 with 91 tools, and its send-message tool takes channel and text. The Slack MCP connector is OAuth 2.1 with 19 tools, and slackmcp_slack_send_message takes channel_id and message. Passing the wrong pair is a runtime failure that reads like a data problem rather than a configuration problem. If you are still choosing between the two, the Slack MCP versus API comparison covers the tradeoffs beyond parameter naming.
This agent uses SlackMCP. Before the connection will authorize, you have to enable Model Context Protocol in the Slack app itself: open the app at api.slack.com/apps, go to Features then Agents & AI Apps, and toggle Model Context Protocol on. Skip that and the OAuth flow has nothing to grant.
MCP-based connectors also return a different response shape. Where the REST connectors hand back a flat payload dict, SlackMCP wraps everything in {"content": [{"type": "text", "text": "..."}]}, and its search tools return that text as markdown rather than structured JSON. The agent's channel resolver parses the channel ID out of the permalink in that markdown. That is not a workaround for a bug; it is what the tool returns, and code that assumes JSON will fail on it.
Google Sheets Expires Tokens Every Hour and Wants Your Own Cloud Project
Google Sheets access tokens last one hour, the shortest window of any major OAuth provider in this stack. Refresh has to be proactive rather than reactive; waiting for a 401 produces a silent failure that looks like a permissions problem and costs an afternoon to diagnose, and in a polling deployment it produces race conditions between concurrent refresh attempts.
There is also no managed-app shortcut. You register your own Google Cloud OAuth credentials, every user's consent screen shows your specific Google Cloud project, and any project requesting user-data scopes has to pass Google's app verification before more than 100 accounts can use it. The spreadsheets scope covers both read and write, which is what this agent needs.
And One Thing That Bites Across All Four
Scalekit auto-suffixes connection names per workspace. The connection you think of as "salesforce" may be salesforce-1 in the dashboard, and Google Sheets may land as something like googlesheets-BOzvgKS0. The generic provider label will not match.
This matters in two places: the startup auth check, and any execute_tool() call made by an identifier that has more than one connection of the same provider type, which fails with multiple connected accounts found. Copy the exact names from your dashboard into SALESFORCE_CONNECTOR, HUBSPOT_CONNECTOR, SLACK_CONNECTOR, and GOOGLE_SHEETS_CONNECTOR in .env. The defaults in the repo are from the workspace it was built against and are unlikely to match yours.
That is four OAuth models, two scope vocabularies, two response envelope shapes, and a naming rule, all before a single line of forecast logic. This is the part Scalekit takes.
Prerequisites
- A Scalekit account; the free tier is sufficient. Credentials come from Developers then API Credentials at app.scalekit.com.
- A Salesforce org with at least one open Opportunity, and an admin who can install the ECA package.
- A HubSpot portal with a Deals pipeline configured. The default Sales Pipeline works.
- A Slack workspace with Model Context Protocol enabled on the app, and a channel the connected account is a member of.
- An existing Google Sheets spreadsheet. The agent creates the destination tab and header row inside it, but not the spreadsheet itself.
- Python 3.11 or newer.
- An OpenRouter API key, optional. Without it the commentary is deterministic and rule-based, and nothing leaves your connected services.
How Scalekit Collapses Four Auth Flows Into One Interface
Scalekit sits between the agent and every service it calls. You configure each connection once in the dashboard; after that, every API call to Salesforce, HubSpot, Slack, and Google Sheets goes through the same actions.execute_tool() method. Token storage, expiry tracking, and refresh are handled server-side. There is no token in your code.
- Configure once, run forever. Each connector completes its auth flow one time. On the first run, any connector that is not ACTIVE surfaces an authorization link. Every subsequent run proceeds straight through.
- One call pattern for everything. No Salesforce SDK to import, no HubSpot client to initialize, no Slack WebClient to configure. Four connectors, one method signature.
- Credentials never touch the agent runtime. The agent passes an identifier and a connection name. Scalekit resolves the right token from the token vault, injects it, makes the call, and returns the result. Nothing in the process holds a credential.
There is a second benefit that shows up in accuracy rather than security. Across these four connectors, Scalekit's catalog exposes 598 tools: 74 for Salesforce, 457 for HubSpot, 19 for Slack MCP, and 48 for Google Sheets. This agent calls nine of them. Handing an LLM the full catalog is both an accuracy problem and a cost problem, and the fix is not better prompting. It is surface reduction: the agent sees only the tools the current user's connected account is authorized to call, not the catalog.
Recommended Reading: Token-Efficient Tool Calling: Auth Overhead in Agent Context
Ready to wire this up? Start free and follow the AgentKit quickstart.
How to Set Up Your Connectors in Scalekit
Get all four active before writing code. The agent's first step is an auth check across all of them, and having them live means you can test the whole pipeline on the first run.
Step 1: Create Your Scalekit Workspace
Create a free account at scalekit.com and a new workspace for this project. Copy SCALEKIT_ENV_URL, SCALEKIT_CLIENT_ID, and SCALEKIT_CLIENT_SECRET into your .env.
Step 2: Add the Salesforce Connector
Go to AgentKit then Connections then Create Connection, find Salesforce, and copy the redirect URI. Complete the External Client App and packaging flow from the Salesforce connector docs, then enter the Consumer Key, Consumer Secret, and package install URL back in Scalekit. Grant read access to the Opportunity object. Have your Salesforce admin install the package before testing.
Step 3: Add the HubSpot Connector
Create a Public app in the HubSpot developer dashboard, paste the Scalekit redirect URI into Auth then Redirect URL, and add crm.objects.deals.read to the app's scopes. Copy the Client ID and Client Secret into Scalekit, and enter the identical scope string in Permissions. Use the plain REST HubSpot connector, not HubSpotMCP.
Step 4: Add the Slack MCP Connector
Toggle Model Context Protocol on in your Slack app under Features then Agents & AI Apps, then create the Slack MCP connection in Scalekit and complete OAuth. Invite the connected account into whatever channel you set as SLACK_CHANNEL.
Step 5: Add the Google Sheets Connector
Complete OAuth with the spreadsheets scope, granting access to the spreadsheet you will use as the log destination. Create that spreadsheet first at sheets.google.com and copy its ID from the URL into GOOGLE_SHEETS_SPREADSHEET_ID.
Then copy the exact, auto-suffixed connection name for each one into the matching *_CONNECTOR variable in .env.
Setting Up Auth With Claude Code
With connections configured, the Scalekit plugin generates the auth scaffold. Install it with two commands:
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 Salesforce, HubSpot, Slack MCP, and Google Sheets.
I need to run SOQL queries against Salesforce, search HubSpot deals and list
deal pipelines, post messages to Slack, and append rows to a Google Sheet.
Use actions.execute_tool() for all connectors, and pass connection_name
explicitly alongside identifier.
That last instruction matters. Passing connection_name on every call is what prevents the multiple connected accounts found error when one identifier holds several connections of the same provider type.
Claude Code generates the client, a shared base connector, and a startup auth check. The base class is the whole auth surface of the agent:
class Connector:
"""Base connector -- shared auth-check and tool-execution logic."""
def __init__(self, actions, connector_name: str, identifier: str):
self.actions = actions
self.connector_name = connector_name
self.identifier = identifier
def check_auth(self) -> bool:
try:
resp = self.actions.get_or_create_connected_account(
connection_name=self.connector_name,
identifier=self.identifier,
)
status = resp.connected_account.status
except Exception as e:
logger.error(f"Failed to check {self.connector_name} auth: {e}")
return False
if status != "ACTIVE":
logger.warning(f"{self.connector_name} ({self.identifier}) -- {status}")
link = self.actions.get_authorization_link(
connection_name=self.connector_name,
identifier=self.identifier,
).link
logger.warning(f"Authorize here: {link}")
return False
logger.info(f"[OK] {self.connector_name} ({self.identifier}) -- ACTIVE")
return True
def execute_tool(self, tool_name: str, **kwargs) -> Dict[str, Any]:
result = self.actions.execute_tool(
tool_name=tool_name,
identifier=self.identifier,
connection_name=self.connector_name,
tool_input=kwargs,
)
return _unwrap_mcp_envelope(result.data or {})
_unwrap_mcp_envelope is the one concession to connector variance in the entire codebase. MCP-based connectors return a content array wrapping a JSON string; REST connectors return a flat dict. The helper unwraps only when the envelope shape is actually present, so both kinds of connector work through the same call path.
Locally, the identity comes from .env. In production, pass each analyst's real user ID as identifier, resolved server-side from your authenticated session, and surface authorization links from your own UI when a connector is not ACTIVE. That single parameter is what turns a one-analyst script into a multi-user product.
Recommended Reading: Why Admin Accounts Are the Wrong Default for AI Agents
Step 1: Fetching Open Pipeline From Both CRMs
Salesforce is the simpler of the two. One SOQL query pulls every open Opportunity with the fields the aggregator needs, ordered by close date:
class SalesforceConnector(Connector):
def list_open_opportunities(self) -> List[Dict]:
query = (
"SELECT Id, Name, StageName, Amount, CloseDate, IsClosed "
"FROM Opportunity WHERE IsClosed = false "
"ORDER BY CloseDate ASC LIMIT 2000"
)
data = self.execute_tool("salesforce_soql_execute", soql_query=query) or {}
return data.get("records") or []
The 2000-row limit is the SOQL page cap. salesforce_query_next_page exists for pagination and is not implemented here; open pipeline larger than 2000 opportunities is a known scaling boundary worth noting before you deploy.
HubSpot needs two calls, and the order is not optional. HubSpot deals carry a dealstage ID, not a label, and nothing on the deal record says whether that stage is open or closed. You have to resolve the pipeline metadata first:
def resolve_hubspot_open_stages(hubspot: HubSpotConnector) -> dict:
pipelines = hubspot.list_deal_pipelines() # hubspot_deal_pipelines_list
open_stage_labels = {}
for pipeline in pipelines:
for stage in pipeline.get("stages", []):
stage_id = stage.get("id")
label = stage.get("label", stage_id)
is_closed = str(stage.get("metadata", {}).get("isClosed", "false")).lower() == "true"
if stage_id and not is_closed:
open_stage_labels[stage_id] = label
return open_stage_labels
Only then can the deal search run, filtered to those stage IDs and paginated by offset:
data = self.execute_tool(
"hubspot_deals_search",
filterGroups=[{
"filters": [{
"propertyName": "dealstage",
"operator": "IN",
"values": open_stage_ids,
}]
}],
properties=["dealname", "amount", "dealstage", "closedate", "pipeline"],
limit=limit,
offset=offset,
)
If pipeline resolution fails, HubSpot contributes zero deals and the error is logged; Salesforce data still flows through. The reverse holds too. Neither CRM being unavailable takes the cycle down, and this degradation is intentional: a partial forecast with a logged gap is more useful on a Monday morning than no forecast at all.
Step 2: Coverage Ratio and At-Risk Flagging
Both sources merge into StageSegment objects keyed on stage label, each carrying a deal count, a total value, the close-date deltas, and a per-source count so the commentary can say "4 Salesforce, 2 HubSpot" for a stage.
Coverage is the standard SaaS pipeline coverage metric:
def calculate_coverage_ratio(total_open_value: float, quota_target: float) -> float:
"""coverage_ratio = total_open_pipeline_value / quota_target"""
if quota_target
QUOTA_TARGET is configured rather than read from either CRM, and that is a real limitation, not an oversight. Neither Salesforce nor HubSpot exposes an authoritative team quota object through the tools available here; HubSpot's hubspot_goal_targets_list manages per-user goal targets, which is a different thing. COVERAGE_RATIO_TARGET defaults to 3.0, the common rule of thumb given win rates in the 20 to 35 percent range, and the overall forecast is flagged AT RISK below it.
Stage-level flags come from three signals present on the records themselves:
An open record's close date has already passed
The timeline slipped and nobody updated it
A late-stage segment holds fewer than 2 records
Too few deals to be a reliable forecast contributor
A late-stage segment holds under 5% of total open value
The deal is not progressing at the rate its stage implies
Late stages are identified by label keyword: negotiation, contract, decision, review, or closing. Each flagged stage lists its specific reasons in the commentary, and the overall AT RISK status is driven independently by coverage.
Step 3: Gating Slack on a Real Pipeline Change
Here is the decision that makes this agent usable rather than annoying.
The obvious design posts once per forecast period. That is wrong in both directions: it goes quiet when the pipeline moves mid-week, and it fires on schedule when nothing has changed. So instead of a calendar trigger, the agent computes a content fingerprint over the pipeline itself and posts only when that fingerprint differs from the last one it posted for this analyst:
def compute_pipeline_fingerprint(segments: Dict, at_risk_flags: Dict) -> str:
payload = {
"segments": {
label: {
"deal_count": segment.deal_count,
"total_value": round(segment.total_value),
"sources": sorted(segment.sources.keys()),
}
for label, segment in sorted(segments.items())
},
"at_risk": sorted(at_risk_flags.keys()),
}
blob = json.dumps(payload, sort_keys=True)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
Values are rounded to whole dollars before hashing so float jitter across runs does not register as a change, and keys are sorted so dict ordering does not either.
Be precise about what this detects. It is aggregate, stage-level change detection, not per-deal detection. A stage gaining a deal, losing value, or newly picking up an at-risk flag changes the fingerprint. A swap that leaves a stage's count and total value identical does not, because there are no per-deal identifiers in the payload. Sub-dollar changes are invisible for the same reason. That tradeoff buys a fingerprint that is cheap to compute and stable across runs, and it is the right one for commentary that talks about stages rather than individual deals.
The practical consequence is that POLLING_MODE=true turns the agent into a genuine change detector. Leave it running hourly and it stays silent until Salesforce or HubSpot actually moves.
Step 4: Drafting Commentary and Posting to Slack
The rule-based draft is computed first and is always the source of truth. It assembles the coverage line, a by-stage breakdown sorted by value, and the at-risk reasons, formatted in Slack mrkdwn.
The LLM pass is optional and strictly cosmetic:
def polish_commentary(rule_based_draft, forecast_period, openrouter_api_key, openrouter_model) -> str:
"""Return an LLM-polished version of the draft, or the draft unchanged on any failure."""
if not openrouter_api_key:
return rule_based_draft
try:
return _polish_with_llm(rule_based_draft, forecast_period, openrouter_api_key, openrouter_model)
except Exception as e:
logger.warning(f"LLM commentary polish failed ({e}) -- using rule-based draft as-is")
return rule_based_draft
The prompt instructs the model to preserve every number, stage name, and at-risk reason exactly, and states that the draft contains the only facts it may reference. The LLM never introduces a figure that was not already computed. If OpenRouter is down, misconfigured, or returns empty content, the cycle continues on the deterministic draft.
One data-handling note worth surfacing to whoever approves this: setting OPENROUTER_API_KEY sends stage names, dollar figures, and at-risk reasons to a third-party API. Leave it unset and all commentary generation stays local.
Posting needs a channel ID, not a name, so the agent resolves one from the other. Raw IDs beginning C, D, G, or U are used as-is without a search round trip; anything else goes through slackmcp_slack_search_channels, whose results arrive as markdown text with the channel ID embedded in an archive permalink:
def send_message(self, channel_id: str, text: str) -> Dict:
"""Passing a user ID as channel_id sends a DM."""
return self.execute_tool(
"slackmcp_slack_send_message",
channel_id=channel_id,
message=text,
)
If the channel cannot be resolved, the run logs a warning and continues to Step 4. The Sheets snapshot is never lost because Slack had a bad day.
Step 5: Logging the Snapshot to Google Sheets
The destination tab is created on first run if missing, and a header row is written once when the tab is empty. After that, every cycle appends one row per stage:
def append_row(self, spreadsheet_id: str, tab_name: str, row: List[Any]) -> Dict:
return self.execute_tool(
"googlesheets_append_values",
spreadsheet_id=spreadsheet_id,
range=f"{tab_name}!A1",
value_input_option="USER_ENTERED",
values=[row],
)
Note value_input_option in snake_case. The upstream Google API calls it valueInputOption, and passing the camelCase form here returns 'valueInputOption' is required, which is a confusing error to debug from the outside.
Each row carries the run date, analyst, forecast period label, stage, sources, open count, open value, coverage ratio, and the at-risk boolean. Because the write is append-only, the tab becomes a running history of how the forecast moved, which is a more useful artifact than the commentary itself. The commentary answers what the pipeline looks like now; the sheet answers when it changed.
The agent deliberately does not create the spreadsheet. googlesheets_create_spreadsheet exists, but calling it on every run, or worse on every misconfigured run, would scatter forecast history across a pile of orphaned files instead of accumulating it in one place. Create one spreadsheet, point GOOGLE_SHEETS_SPREADSHEET_ID at it, and let the agent manage the tab inside it.
How to Run the Full Pipeline
pip install -r requirements.txt
python run_flow.py
A typical run:
Step 0: Checking connector auth
[OK] salesforce-1 (analyst@yourcompany.com) -- ACTIVE
[OK] hubspot (analyst@yourcompany.com) -- ACTIVE
[OK] slackmcp (analyst@yourcompany.com) -- ACTIVE
[OK] googlesheets-BOzvgKS0 (analyst@yourcompany.com) -- ACTIVE
Step 0.5: Verifying Google Sheets destination and HubSpot pipelines
[OK] Google Sheets tab 'Forecast Log' already exists
[OK] Resolved 5 open HubSpot stage(s) across 1 pipeline(s)
Step 1: Fetching open pipeline from Salesforce and HubSpot
Fetched 11 Salesforce opportunity(ies), 7 HubSpot deal(s)
Step 2: Calculating coverage ratio and flagging at-risk stages
6 stage(s), $352,000 total open pipeline, 3.52x coverage (target 3.0x), 2 at-risk stage(s)
Step 3: Posting commentary to Slack (pipeline changed since last post)
[OK] Commentary posted to Slack (#revenue-ops -> C09K0K2RZ6Y)
Step 4: Logging pipeline snapshot to Google Sheets
[OK] Logged 6/6 stage row(s) to Google Sheets
For weekly cadence, a cron entry is the primary deployment pattern:
0 9 * * MON cd /path/to/agent && python run_flow.py >> logs/run.log 2>&1
For continuous change detection, set POLLING_MODE=true and an interval. The agent exits with 0 on success, 1 on error, 2 when there is no open pipeline in either CRM, and 130 on graceful shutdown, which makes it straightforward to wire into a scheduler that cares about outcomes.
What to Check Before You Go Live
Salesforce. Confirm the ECA scopes match the scopes entered in Scalekit exactly. Open pipeline above 2000 opportunities needs salesforce_query_next_page pagination added to list_open_opportunities.
HubSpot. Verify HUBSPOT_CONNECTOR points at an ACTIVE plain REST connection with CRM read scopes. If commentary reports zero open pipeline while deals clearly exist, check the stage isClosed metadata via hubspot_deal_pipelines_list; only stages with isClosed: false are queried.
Slack. The connected account must be a member of the target channel. If the channel name search returns nothing, set SLACK_CHANNEL to a literal channel ID instead.
Quota and coverage. QUOTA_TARGET must be in the same currency units as the Amount and amount fields your CRMs return. A mismatched unit produces a coverage ratio that is wrong by orders of magnitude and looks plausible enough to go unnoticed.
One accepted race. If the process dies between the successful Slack post and the fingerprint being written to disk, the next run sends one duplicate message. That is a narrow crash window, and the alternative is idempotency-key machinery for a single repeated Slack message. The tradeoff was made knowingly; make sure it is the right one for you.
Adding Another Source
The pattern extends without touching auth. Pulling renewal risk from Gainsight, cross-referencing win rates from Gong, or writing the snapshot to Snowflake instead of Sheets all follow the same three steps: add the connection in the dashboard, mirror an existing class in connectors.py, read result.data. MCP envelopes unwrap themselves.
Scalekit maintains each connector. You maintain none of them.
The full source is on GitHub. Clone it, configure your four connections, and have it running in under 30 minutes. Browse the rest of the catalog in the connector docs, check the Python SDK reference, or start free and work through the AgentKit quickstart.
For adjacent patterns built on the same auth layer, see how to surface Salesforce customer insights into Slack, score deal risk with Gong and Attio, or push HubSpot deal updates to Slack.
FAQ
Why gate Slack on a pipeline change instead of posting once a week?
A calendar trigger fails in both directions. It stays quiet when the pipeline moves on a Wednesday, and it fires on Monday whether or not anything changed. The fingerprint gate posts when there is something to say. FORECAST_PERIOD is only a display label; it has no effect on whether Slack gets a post.
What happens if Salesforce or HubSpot is unavailable mid-run?
Each source degrades to zero records with the failure logged, and the other still contributes. If both come back empty, the cycle exits 2, which signals no data rather than an error, without posting or writing.
Why is the quota target a config value rather than read from the CRM?
Neither Salesforce nor HubSpot exposes an authoritative team quota object through the tools available to this agent. HubSpot's hubspot_goal_targets_list manages per-user goal targets, not a single team figure. Setting QUOTA_TARGET explicitly is more honest than deriving something that looks authoritative and is not.
Can I run this for several analysts or several teams?
Yes, and this is where the identifier does the work. Pass each analyst's real user ID as identifier on every call rather than a shared RevOps bot account. Scalekit stores and refreshes each analyst's tokens independently, so there is no credential sharing and no cross-team token collision. Fingerprint state is already keyed per analyst. For the wider pattern, see single-tenant versus multi-tenant tool calling and access control for multi-tenant AI agents.
Should I use the Slack or Slack MCP connector?
This agent is built against Slack MCP, whose slackmcp_slack_send_message takes channel_id and message. The plain Slack connector's slack_send_message takes channel and text. Either works; the code has to match the one you connect. Check which variant holds an ACTIVE connected account before you start.
Can the LLM change the numbers?
No. The rule-based draft is computed from CRM data first and passed to the model as the only source of facts it may reference. If the LLM call fails, times out, or returns empty content, the cycle falls back to that draft unchanged. Leaving OPENROUTER_API_KEY unset keeps commentary generation entirely local.