TL;DR
- Salesloft already ships a daily queue: salesloft_tasks_list with time_interval_filter="today" and sort_by="due_date". It orders by when a step was scheduled, not by whether the prospect is engaging. A priority agent re-ranks that queue against reply state, view and click state, cadence position, and open opportunity stage.
- Ranking is the easy part. The acting rep must be resolved from the connected account's credential (salesloft_users_get_current takes zero parameters), never from the prompt, because every user_guid and owner_id filter downstream inherits that answer.
- Salesloft access tokens expire in 7,200 seconds and every refresh revokes all prior refresh tokens. Sixty reps means sixty rotating credentials that must be written back atomically; two agent threads refreshing the same rep concurrently is a live failure mode, not a hypothetical.
- In the Claude Agent SDK, allowed_tools auto-approves calls; it does not decide which tools exist. tools and strict_mcp_config control availability inside your process. Neither is an authorization boundary, because both live on the client the model is running in.
- Scalekit's Virtual MCP Server puts the boundary server-side: one server definition per agent role declaring 10 of the Salesloft connector's 56 tools, and a short-lived session token minted per run scoped to one rep's connected account. What the rep can't do, the agent can't do.
- A PreToolUse hook enforces the rules that tool names cannot express: argument caps that keep results under the 25,000-token MCP output ceiling, page caps that respect Salesloft's 600-cost-per-minute team budget, and a deny on current_state="completed" so the agent reorders the day without closing work on the rep's behalf.
Salesloft's task list is sorted by due date. That is correct behavior for a task list and wrong behavior for a morning. A prospect who opened the last email three times yesterday and replied to nothing sits at position 34 because their cadence step lands Thursday, while a step due at 9am belongs to an account whose opportunity closed lost last week. The rep works top-down, burns the first ninety minutes, and never reaches the two people who were actually moving.
Re-ranking that list is a scoring problem, and scoring is the part a model does well with a rubric and real signals. The part that breaks in production is quieter: the ranking is per rep, and a per-rep ranking is only correct if the agent's Salesloft credential belongs to that rep. Get that wrong and the agent does not throw. It returns a confident, well-formatted priority list for the wrong person's book, or a list built from whatever the shared service account happens to own. That failure surfaces in a pipeline review three weeks later, or never.
So the hard part is not the ranking. It is making the identity behind each tool call a property of the infrastructure rather than a string in a prompt.
What the agent computes, and where the reasoning boundary sits
The retrieval and the write-back are a deterministic pipeline: fixed tools, fixed order, fixed filters. Only the ranking step is a reasoning loop, and it is constrained by a rubric and a fixed output shape.
Deterministic or reasoning
Deterministic, runs before the model
salesloft_users_get_current
salesloft_actions_list, salesloft_tasks_list, salesloft_cadence_memberships_list
Pull engagement and deal context
salesloft_emails_list, salesloft_people_list, salesloft_opportunity_people_list, salesloft_opportunities_list
salesloft_notes_create, salesloft_tasks_update
The signals come from filters the connector already exposes, which is why this is a scoring problem and not a data engineering project:
- salesloft_emails_list accepts has_replies, has_views, has_clicks, bounced, and sent_at_gte. Replies, opens, and clicks in the last 72 hours are three separate boolean queries against the same rep's sent mail.
- salesloft_actions_list accepts due_on_lte, user_guid, type, and cadence_id. Actions are per-person executions of a cadence step, distinct from salesloft_steps_list, which returns the step definitions.
- salesloft_cadence_memberships_list accepts currently_on_cadence, so a person who has fallen off a cadence is distinguishable from one mid-sequence.
- salesloft_opportunity_people_list joins a person_id to an opportunity_id, and salesloft_opportunities_list accepts stage_name, is_closed, and close_date_lte. Deal stage enters the score through that join, not through a guess about account tier.
The rubric goes in the system prompt as fixed weights so the ordering is reproducible and reviewable:
Replied in last 72h, no follow-up logged
salesloft_emails_list(has_replies=true)
salesloft_emails_list(has_clicks=true)
Viewed 2+ times, never replied
salesloft_emails_list(has_views=true)
Linked to an open opportunity closing within 30 days
salesloft_opportunities_list(is_closed=false, close_date_lte=…)
salesloft_actions_list(due_on_lt=today)
Linked opportunity closed
salesloft_opportunities_list(is_closed=true)
Bounced, or do_not_contact
salesloft_emails_list(bounced=true)
Full parameter surfaces for each tool live on the Salesloft connector page; salesloft_people_list alone accepts 53 parameters, and that page is the reference rather than this one.
Recommended reading: Deal risk intelligence agent with Attio and the Claude Agent SDK covers the same reasoning-boundary split against a CRM object model.
The acting rep is decided by the credential, not by the prompt
salesloft_users_get_current accepts no parameters. It returns whoever the presented access token belongs to. That property is the whole authentication story for this agent, because everything downstream is keyed off the answer: user_guid on actions, user_id on tasks and emails, owner_id on accounts, user_guid on the note that gets written back.
Two consequences follow, and they are worth stating separately:
- The rep identity is not an agent input. If the agent asks the model for a user_guid, or reads one out of the prompt, a malformed cadence name or an injected string in a prospect's note field can redirect the entire run to another rep's book. Resolve it out of band, before the model sees anything, and pin the result.
- Attribution is not cosmetic. A note written under a service account's user_guid is invisible in the rep's activity feed and wrong in the manager's rollup. Cadence ownership, activity attribution, and manager visibility in Salesloft all depend on the action being recorded under the human who triggered it.
Scalekit models this as a connected account: a per-user, org-scoped instance of a connection, holding that one rep's OAuth grant. The agent references an identifier; it never handles a token. Credentials never touch the agent runtime.
Sixty reps, twelve workspaces, one rotating refresh token each
Run this for a 60-rep sales org across 12 customer workspaces and the credential surface is the deployment.
Salesloft's own OAuth documentation sets the terms: access tokens carry expires_in: 7200, and on refresh all previous refresh tokens are revoked, so the new refresh_token must be persisted or that rep's grant is dead. The client credentials flow issues no refresh token at all, which rules it out for per-rep delegation.
That produces four problems a scheduled agent hits and a demo does not:
What it looks like at 7am
Two rep runs share a stale token record; both refresh; the second write loses
One rep's run 401s with no retry path
New refresh_token not persisted atomically before the old one is revoked
That rep needs full re-authorization, silently
Mid-run expiry inside a retry loop
A 429 backoff outlasts the two-hour token window
Reported against a Salesloft connector as Airbyte issue #13660
A rep leaves; Salesloft disables them; your stored refresh token does not know
A scheduled job keeps trying, and a silent 401 in a background run is not an alert
Refresh handling has to be proactive and coordinated. Waiting for a 401 means multiple runs discover expiry simultaneously and each attempts refresh unaware of the others. Scalekit's token vault holds the grants encrypted per connected account and orchestrates rotation centrally, so the agent executes against a scoped identifier rather than a token it has to keep alive. The reasoning behind that split is covered in token refresh for long-running agents and credential ownership across agent tool-calling patterns.
The offboarding case deserves its own note, because it is the one teams discover during a security review rather than in code: revoking an employee's agent access is a connected-account lifecycle operation, not a prompt change.
allowed_tools is not an authorization boundary
This is the precision point that separates a Claude Agent SDK agent that survives review from one that does not, and the SDK's own type definitions are explicit about it.
Which built-in tools exist. [] disables all of them
Which tool names run without a permission prompt
Which tool names are refused
Ignores every MCP config the CLI would otherwise load, including project .mcp.json, user settings, and plugin servers. Defaults to False
Which tools the server will serve at all
Which of those calls the credential can perform
Treating a client-side allowlist as the boundary fails in ways the community has already documented:
- Sub-agents inherit the parent session's full MCP tool set even when the agent definition restricts tools.
- Tool definitions alone can exceed the context window before a single call runs: prompt is too long: 209117 tokens > 200000 maximum.
- Tool schemas silently dominate context; one report shows /doctor reporting roughly 144,802 tokens of MCP tool context, with a single server contributing about 125,964 of it.
Point the whole Salesloft connector at a session and this becomes arithmetic rather than speculation. Fifty-six tools, several with 27 to 53 parameters each, is tens of thousands of tokens spent before the agent reads a single email record, and a decision space the model was never designed to select from at that width. Tool bloat is an accuracy problem and a cost problem at the same time. Scoped surfaces fix both, and the fix is not better prompting; it is surface reduction.
Set strict_mcp_config=True and setting_sources=[] anyway, because an unattended process should not inherit a .mcp.json that happens to exist on the deploy host. Just do not mistake those flags for the boundary. Deeper treatment in least privilege for agent tool calls and token-efficient tool calling.
Prerequisites before the code. A Salesloft OAuth application created under Settings → Your Applications → OAuth Applications with read and write permissions, its client ID and secret registered in the Scalekit dashboard under AgentKit → Connections, the Claude Code CLI on PATH, and ANTHROPIC_API_KEY set. Note the Connection name you create in the dashboard: the string must match connection_name in your code exactly. This is the single most common integration error. Setup steps are on the Salesloft connector page.
pip install scalekit-sdk-python claude-agent-sdk
# .env
SCALEKIT_ENVIRONMENT_URL=<your-environment-url>
SCALEKIT_CLIENT_ID=<your-client-id>
SCALEKIT_CLIENT_SECRET=<your-client-secret>
ANTHROPIC_API_KEY=<your-anthropic-key>
One Virtual MCP server per agent role, ten tools out of fifty-six
The Virtual MCP Server is created once per agent role, not once per user. The response carries a static mcp_server_url reused for every rep and every run; identity arrives later, in the session token.
# provision.py: run once per environment, not per rep, not per run.
import os
from scalekit import ScalekitClient
from scalekit.actions.models.mcp_config import McpConfigConnectionToolMapping
scalekit_client = ScalekitClient(
env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"],
client_id=os.environ["SCALEKIT_CLIENT_ID"],
client_secret=os.environ["SCALEKIT_CLIENT_SECRET"],
)
# 10 of the connector's 56 tools. Everything absent here is unreachable for
# this agent role, regardless of what the model asks for or what the client
# allowlist says, because the server will not serve it.
PRIORITY_AGENT_TOOLS = [
# Identity: zero params, resolves from the credential.
"salesloft_users_get_current",
# Today's work.
"salesloft_actions_list",
"salesloft_tasks_list",
"salesloft_cadence_memberships_list",
# Ranking signals.
"salesloft_emails_list",
"salesloft_people_list",
"salesloft_opportunity_people_list",
"salesloft_opportunities_list",
# Write-back. Note what is NOT here: no cadence_memberships_create
# (enrollment), no people_delete, no people_update, no emails send path.
"salesloft_notes_create",
"salesloft_tasks_update",
]
vmcp_response = scalekit_client.actions.mcp.create_config(
name="salesloft-daily-priority",
description="Read-mostly daily priority ranking for a single Salesloft rep.",
connection_tool_mappings=[
McpConfigConnectionToolMapping(
# MUST match the Connection name in AgentKit > Connections exactly.
connection_name="salesloft",
tools=PRIORITY_AGENT_TOOLS,
)
],
)
# Persist both. They are configuration, not per-run state.
print("config_id:", vmcp_response.config.id)
print("mcp_server_url:", vmcp_response.config.mcp_server_url)
Omitting tools on a mapping exposes every tool for that connection, which is the default worth avoiding here. The mechanics of the object model are documented under Virtual MCP servers and set up and connect a Virtual MCP server.
Two exclusions above are deliberate and load-bearing. salesloft_cadence_memberships_create would let a ranking agent enroll prospects into sequences, which is a different agent with a different risk profile. salesloft_tasks_update is included, but section eight constrains what it may set, because a tool name is too coarse an authorization unit for it.
Retrieve the authorized surface for this rep, then mint a token for the run
Before a run, two questions need answering, and they are answered at different layers. Is this rep's Salesloft connection still live? And which tools is this rep's connected account actually authorized to call?
The second question is not tool discovery. The agent is not exploring an unknown surface; it is retrieving the deterministic set of tools the current connected account is authorized to call, which is the intersection of the Virtual MCP mapping and that rep's grant. A rep whose Salesloft permissions were narrowed sees a narrower surface than a rep in the same role, from the same server definition, with no code change.
# preflight.py
import os
from datetime import timedelta
from scalekit import ScalekitClient
from scalekit.v1.tools.tools_pb2 import ScopedToolFilter
scalekit_client = ScalekitClient(
env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"],
client_id=os.environ["SCALEKIT_CLIENT_ID"],
client_secret=os.environ["SCALEKIT_CLIENT_SECRET"],
)
CONFIG_ID = os.environ["SALESLOFT_VMCP_CONFIG_ID"]
CONNECTION_NAME = "salesloft" # matches the dashboard Connection name
def assert_connection_active(rep_identifier: str) -> None:
"""Fail the run before minting a token if the rep's grant is not usable.
An inactive grant discovered mid-run costs a partial ranking and a
confusing note. Discovered here, it costs one re-auth link.
"""
accounts = scalekit_client.actions.mcp.list_mcp_connected_accounts(
config_id=CONFIG_ID,
identifier=rep_identifier,
include_auth_link=True, # returns a re-auth URL for inactive accounts
)
for account in accounts.connected_accounts:
if account.connected_account_status != "ACTIVE":
raise RuntimeError(
f"{account.connection_name} needs authorization for "
f"{rep_identifier}: {account.authentication_link}"
)
def authorized_tools(rep_identifier: str) -> list[str]:
"""Retrieve the tools this rep's connected account is authorized to call.
Log this per run. When a ranking looks wrong, the first question is
whether the tool that supplies a signal was on the surface at all.
"""
response = scalekit_client.tools.list_scoped_tools(
rep_identifier,
# connection_names takes the dashboard Connection name, not a
# provider slug. Mismatches here return an empty surface, not an error.
filter=ScopedToolFilter(connection_names=[CONNECTION_NAME]),
page_size=50,
)
return [tool.tool_name for tool in response.tools]
def resolve_acting_rep(rep_identifier: str) -> dict:
"""Resolve the Salesloft user behind this rep's credential.
Deliberately outside the model loop. The agent is told which rep it acts
for; the credential decided that, and the PreToolUse hook enforces it.
"""
result = scalekit_client.actions.execute_tool(
tool_input={}, # zero-parameter tool by design
tool_name="salesloft_users_get_current",
identifier=rep_identifier,
connection_name=CONNECTION_NAME,
)
return result.data
def mint_session_token(rep_identifier: str) -> str:
"""Mint a fresh token per run, scoped to this rep's connected account.
Expiry is set above the expected run duration and no higher. Never reuse
a token across runs or across reps.
"""
token_response = scalekit_client.actions.mcp.create_session_token(
mcp_config_id=CONFIG_ID,
identifier=rep_identifier,
expiry=timedelta(minutes=20),
)
return token_response.token
One server definition serves all reps. Each run gets a short-lived session token scoped to that rep's connected accounts. The endpoint is static; the identity is not. Cross-tenant tool calling requires per-tenant authorization, and there is no shortcut, which is the argument developed in access control for multi-tenant AI agents and single versus multi-tenant tool calling.
Per-call authorization lives in the PreToolUse hook
The Virtual MCP mapping decides which tools exist. It cannot decide what arguments are acceptable, and for this agent three rules are argument-level.
MCP tools reaching the hook are named mcp__<server_key>__<tool_name>, where <server_key> is the key you use in mcp_servers. Keying the server salesloft yields mcp__salesloft__salesloft_actions_list, with the doubled segment. Allowlists and matchers written against the single-prefix form match nothing.
# gates.py
from typing import Any
# Reordering the day is in scope. Closing work on the rep's behalf is not.
FORBIDDEN_TASK_STATES = {"completed"}
# Salesloft penalizes deep pagination on a sliding cost scale.
MAX_PAGE = 100
MAX_PER_PAGE = 50
def _deny(reason: str) -> dict[str, Any]:
"""Deny takes priority over defer, ask, and allow across all hooks."""
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
# The model reads this and adapts rather than retrying blindly.
"permissionDecisionReason": reason,
}
}
def build_salesloft_gate(expected_user_guid: str):
"""Close over the rep resolved from the credential, before the run began."""
async def gate(
input_data: dict[str, Any], tool_use_id: str | None, context: Any
) -> dict[str, Any]:
tool_name = input_data["tool_name"]
args = input_data.get("tool_input") or {}
# 1. Attribution. Any tool that accepts a user identity must carry the
# rep the credential resolved to, or none at all (Salesloft defaults
# to the authenticated user).
for field in ("user_guid", "user_id", "owned_by_guid", "owner_id"):
value = args.get(field)
if value not in (None, "", expected_user_guid):
return _deny(
f"{field}={value} does not match the acting rep "
f"{expected_user_guid}. Omit it to use the authenticated user."
)
# 2. Result size. A response over 25,000 tokens is written to a file and
# replaced with an error naming the path. This agent runs with
# tools=[], so no Read tool exists to recover it: the run stalls.
# Cap the arguments instead of relying on the overflow path.
if int(args.get("per_page") or 0) > MAX_PER_PAGE:
return _deny(f"per_page must be <= {MAX_PER_PAGE} to bound result size.")
if int(args.get("page") or 0) > MAX_PAGE:
return _deny(
f"page must be <= {MAX_PAGE}. Narrow with updated_at_gte or ids "
f"instead of paging deeper."
)
if tool_name.endswith("salesloft_people_list") and not args.get("ids"):
return _deny(
"salesloft_people_list requires ids. Hydrate the person IDs "
"returned by salesloft_actions_list in one batched call."
)
# 3. Write scope. The agent ranks and annotates; it does not complete.
if tool_name.endswith("salesloft_tasks_update"):
if args.get("current_state") in FORBIDDEN_TASK_STATES:
return _deny(
"This agent may reprioritize a task but may not mark it "
"completed. Only the rep completes work."
)
if tool_name.endswith("salesloft_notes_create"):
if args.get("associated_with_type") not in ("person", "account"):
return _deny(
"associated_with_type must be 'person' or 'account'."
)
return {} # allow, subject to remaining permission evaluation
return gate
def build_audit_emitter(run_id: str, rep_identifier: str, tenant_id: str):
"""Emit one structured event per tool call, without blocking the run."""
import asyncio
async def emit(
input_data: dict[str, Any], tool_use_id: str | None, context: Any
) -> dict[str, Any]:
event = {
"run_id": run_id,
"tenant_id": tenant_id,
"rep_identifier": rep_identifier,
"session_id": input_data.get("session_id"),
"tool_use_id": tool_use_id,
"tool_name": input_data.get("tool_name"),
"tool_input": input_data.get("tool_input"),
}
# async_ tells the agent to proceed without waiting. Valid because this
# hook only records; it never influences the permission decision.
asyncio.create_task(ship_to_siem(event))
return {"async_": True, "asyncTimeout": 30_000}
return emit
The precedence rule matters when both hooks are registered: across hooks and permission rules, deny beats defer, beats ask, beats allow, so a single deny blocks the call regardless of what the audit hook returns.
What the audit trail has to answer
Salesloft's API Logs record which integration hit which endpoint on a given team. They cannot say which human triggered a given agent run, or which of your 60 scheduled runs produced a given note. That correlation exists only if you emit it, and the hook is the place it exists. Scalekit's agent logs carry the complementary half: which connected account authorized the call, which tool ran, and the outcome, separated into connector errors versus infrastructure errors. Together they answer the one question a reviewer asks: what did the agent do on behalf of this rep, in this org, on this date. The design constraints are laid out in audit trails for agent auth.
The run loop
The Agent SDK owns the agent loop, so there is no stop_reason branch to write. What you own is the options object and the message stream, and both carry information a scheduled run must not discard.
# run.py
import asyncio
import os
import uuid
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
HookMatcher,
ResultMessage,
SystemMessage,
TextBlock,
ToolUseBlock,
)
from gates import build_audit_emitter, build_salesloft_gate
from preflight import (
assert_connection_active,
authorized_tools,
mint_session_token,
resolve_acting_rep,
)
MCP_SERVER_URL = os.environ["SALESLOFT_VMCP_SERVER_URL"]
SERVER_KEY = "salesloft" # produces mcp__salesloft__<tool_name>
RUBRIC = """You rank one Salesloft rep's actions for today.
Scoring (apply exactly; do not invent weights):
+40 replied in last 72h with no follow-up logged
+25 clicked in last 72h
+15 viewed 2+ times, never replied
+30 linked to an open opportunity closing within 30 days
+10 cadence step overdue
-50 linked opportunity is closed
exclude bounced or do_not_contact
Procedure:
1. Pull due actions and scheduled tasks for today.
2. Batch-hydrate the person IDs you got back in ONE salesloft_people_list
call using ids. Never page through people.
3. Query engagement signals with sent_at_gte set to 72 hours ago.
4. Join people to opportunities via salesloft_opportunity_people_list, then
read stage from salesloft_opportunities_list.
5. Rank, then write the top 10 as one note via salesloft_notes_create with
associated_with_type='person' on the highest-ranked person.
6. For any top-10 item whose task is not due today, reprioritize it with
salesloft_tasks_update by setting due_date only.
Never set user_guid, user_id, or owner_id on any call. Never mark a task
completed. If a tool call is denied, read the reason and adapt; do not retry
the same arguments."""
async def run_for_rep(rep_identifier: str, tenant_id: str) -> dict:
run_id = str(uuid.uuid4())
# Pre-flight, outside the model. Order matters: verify the grant, resolve
# identity from the credential, then mint a token for this run only.
assert_connection_active(rep_identifier)
acting_rep = resolve_acting_rep(rep_identifier)
expected_user_guid = acting_rep["guid"]
surface = authorized_tools(rep_identifier)
session_token = mint_session_token(rep_identifier)
options = ClaudeAgentOptions(
model="sonnet",
# No built-in tools. This agent reads Salesloft and writes Salesloft;
# it has no business with the filesystem, Bash, or web fetch.
tools=[],
mcp_servers={
SERVER_KEY: {
"type": "http",
"url": MCP_SERVER_URL,
# Per-run, per-rep bearer. The only credential in this process,
# and it grants nothing beyond this rep's connected account.
"headers": {"Authorization": f"Bearer {session_token}"},
}
},
# Ignore any .mcp.json, user settings, or plugin server on the host.
strict_mcp_config=True,
setting_sources=[],
# Auto-approve so an unattended run never waits on a prompt. This is a
# prompt-suppression list, not the authorization boundary; the boundary
# is the Virtual MCP mapping plus the hook below.
allowed_tools=[f"mcp__{SERVER_KEY}__*"],
max_turns=24,
system_prompt=(
f"{RUBRIC}\n\nYou are acting for Salesloft user "
f"{acting_rep['name']} (guid {expected_user_guid})."
),
hooks={
"PreToolUse": [
HookMatcher(
matcher=f"^mcp__{SERVER_KEY}__",
hooks=[build_salesloft_gate(expected_user_guid)],
),
HookMatcher(
hooks=[build_audit_emitter(run_id, rep_identifier, tenant_id)]
),
]
},
# Raise the MCP result ceiling only if the argument caps are not enough.
env={"MAX_MCP_OUTPUT_TOKENS": "40000"},
)
tool_calls: list[str] = []
async with ClaudeSDKClient(options=options) as client:
await client.query("Build today's priority list.")
async for message in client.receive_response():
# The init frame reports per-server connection status. A remote
# server that returned an auth challenge shows needs-auth here, and
# the run would otherwise continue with no Salesloft tools at all.
if isinstance(message, SystemMessage) and message.subtype == "init":
for server in message.data.get("mcp_servers", []):
if server.get("status") in ("failed", "needs-auth"):
raise RuntimeError(
f"run {run_id}: MCP server {server.get('name')} "
f"unusable ({server.get('status')})"
)
elif isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, ToolUseBlock):
tool_calls.append(block.name)
elif isinstance(block, TextBlock):
print(block.text)
elif isinstance(message, ResultMessage):
# permission_denials is the signal that the gate fired. A run
# that "succeeded" with denials is a run whose ranking may be
# missing an input; treat it as degraded, not clean.
return {
"run_id": run_id,
"rep_identifier": rep_identifier,
"tenant_id": tenant_id,
"authorized_surface": surface,
"tool_calls": tool_calls,
"denials": message.permission_denials or [],
"turns": message.num_turns,
"cost_usd": message.total_cost_usd,
"is_error": message.is_error,
"summary": message.result,
}
raise RuntimeError(f"run {run_id}: stream ended without a result message")
Three details in that loop are worth naming, because each one hides a silent failure:
- A remote MCP server that fails to connect does not raise. It reports status on the init frame, and the agent continues without those tools. Without the check, a rep whose grant expired gets a ranking built from nothing. client.get_mcp_status() and client.reconnect_mcp_server() cover status changes later in a session, since a connected server moves back to pending while reconnecting and reports failed or needs-auth after repeated attempts.
- permission_denials on ResultMessage is the gate's output channel. A run with denials completed, but possibly on partial signals. Alert on it.
- Hooks may not fire when a run hits max_turns. A run that terminates on the turn limit can end without its final audit events, so treat num_turns == max_turns as an incomplete run.
If you want the ranking returned as a validated object rather than parsed from text, the SDK exposes output_format on options and structured_output on ResultMessage.
Three failures that only appear in production
Result exceeds the MCP output ceiling
salesloft_emails_list or salesloft_people_list returning wide records at per_page=100; the result is written to a file and replaced with an error naming the path, and with tools=[] there is no Read tool to recover it
Cap per_page and require ids in the PreToolUse hook; raise MAX_MCP_OUTPUT_TOKENS only as a second line
Rate limit exhaustion at fan-out
Salesloft's rate limit is 600 cost per minute at the team level, shared with every other integration the customer runs. Roughly 10 calls per rep run, times 60 reps launched together, is the entire minute budget
Stagger runs; target a fraction of the budget, not all of it
Deep pagination cost blowup
Pages 101 to 150 cost 3 points, 151 to 250 cost 8, 251 to 500 cost 10, and 501 and above cost 30. One agent walking page numbers can spend the team budget alone
Deny page > 100 in the hook; use updated_at_gte cursors, which is Salesloft's own guidance
The scheduling shape follows from the second row rather than from preference:
# schedule.py
import asyncio
# ~10 tool calls per rep run at 1 cost each on capped pages.
CALLS_PER_RUN = 10
# Claim a third of the team's 600-cost minute; the customer's other
# integrations need the rest.
BUDGET_PER_MINUTE = 200
CONCURRENCY = BUDGET_PER_MINUTE // CALLS_PER_RUN # 20 concurrent rep runs
async def run_morning(reps: list[tuple[str, str]]) -> list[dict]:
"""reps is a list of (rep_identifier, tenant_id) pairs."""
semaphore = asyncio.Semaphore(CONCURRENCY)
async def guarded(rep_identifier: str, tenant_id: str) -> dict:
async with semaphore:
try:
return await run_for_rep(rep_identifier, tenant_id)
except Exception as exc:
# One rep's dead grant must not take down the other 59.
return {
"rep_identifier": rep_identifier,
"tenant_id": tenant_id,
"is_error": True,
"error": str(exc),
}
return await asyncio.gather(
*(guarded(rep, tenant) for rep, tenant in reps)
)
Per-tenant budgets belong in the platform rather than in this file once you are past one customer.
Next steps to start building
- Create the Salesloft OAuth application, register its client ID and secret under AgentKit → Connections, and note the Connection name exactly as you typed it. Follow the Salesloft connector setup.
- Authorize one rep as a connected account from the dashboard, then call salesloft_users_get_current through execute_tool and confirm the returned guid is that rep and not a service account. See authorize a user.
- Run provision.py to create the Virtual MCP server with the 10-tool mapping, and store config_id and mcp_server_url as configuration.
- Run authorized_tools() for two reps with different Salesloft permissions and diff the surfaces. If they are identical, the credential is not doing the scoping and something upstream is shared.
- Wire the PreToolUse gate before the first unattended run, then deliberately prompt the agent to complete a task and confirm the denial lands in permission_denials.
- Schedule for one rep, read the audit events end to end, then widen to the team inside a staggered budget.
Adjacent patterns worth reading next: the sales call prep agent for the pre-meeting counterpart, and the outbound prospecting agent for the cadence-write side of this surface.
FAQs
Can I run this on one service-account token for the whole team instead of per-rep connected accounts?
It will produce output, and the output will be wrong. salesloft_users_get_current resolves to the service account, so every user_guid and owner_id filter downstream targets the service account's book rather than the rep's. Notes land under the wrong identity and vanish from the rep's activity feed. Separately, a service account is typically provisioned with admin scope, which gives the agent more access than any individual rep has, and one credential compromise then spans every rep's data with attribution to no one. Shared credentials are a single-user pattern; they do not survive a second user.
Does allowed_tools stop the agent from calling a tool I left out?
No. It auto-approves the names you list so an unattended run does not stall on a permission prompt. It does not remove tools from the surface, and the SDK documents that Claude still sees tools it lacks permission for. Availability inside your process is tools, disallowed_tools, and strict_mcp_config; availability as an enforced boundary is the Virtual MCP tool mapping, because that decision is made server-side where the model cannot reach it.
A rep revokes the Salesloft grant while a run is in flight. What happens?
The MCP server begins returning auth challenges, the transport moves to needs-auth after repeated reconnect attempts, and the run continues without Salesloft tools unless you check for it. Read the init frame status at the start and client.get_mcp_status() during long runs. On the Scalekit side, list_mcp_connected_accounts with include_auth_link=True gives you the pre-flight check and the re-authorization URL to surface, and subscribing to connected-account events removes the polling entirely.
Why resolve the acting rep outside the agent loop rather than letting the model call the tool?
Two reasons. It removes a turn from every run, and more importantly it produces a value the hook can enforce against. If the rep GUID is only ever a model output, then a prompt-injected string in a prospect's note field or cadence name can change who the run acts for, and nothing in the process is positioned to notice. Resolving it from the credential first makes the identity a precondition of the run rather than a result of it.
Can the agent enroll people into cadences based on its own ranking?
Not with this server definition. salesloft_cadence_memberships_create is absent from the tool mapping, so the server will not serve it whatever the model asks. Enrollment changes who receives outbound mail and belongs to a separate agent role with its own approval path.
How do I keep 60 rep runs inside Salesloft's rate limit?
Budget in cost, not requests, and remember the budget is the customer's whole team rather than your integration. At roughly 10 capped calls per rep run, 20 concurrent runs consumes about 200 of the 600 cost available in a minute. Deny page > 100 at the hook so no single run can spend the budget on deep pagination, and prefer updated_at_gte cursors and batched ids lookups over paging.
Does the model ever see a Salesloft token?
No. The only credential in the agent process is the per-run session token, which is scoped to one rep's connected accounts and expires on the timer you set. The Salesloft access and refresh tokens stay in the vault, and rotation is handled there, so credentials never touch the agent runtime or the model's context.