TL;DR
- An ICP sentence does not map to one Clay call. A target account list is claymcp_query_objects over Clay Audiences, then claymcp_find_and_enrich_company per net-new domain, then claymcp_add_company_data_points for the qualifying signals, then claymcp_get_task_context to read any of it back.
- Clay's MCP surface is governed per rep. Credit limits, Salesforce account ownership, and which Functions are callable are all configured per user in Clay's own settings, so the identity on the connection changes the answer, not just the permission.
- onlyMine on claymcp_query_objects resolves against the caller's owned Salesforce accounts. Under one shared workspace credential it returns the wrong book and never raises an error.
- Clay cannot revoke a rep's MCP grant from its MCP users page. The connected account in your own auth layer is the only revocation surface you actually control.
- The Scalekit LangChain adapter binds connected_account_id into each StructuredTool at construction time and returns tool failures as ordinary tool text. That makes the tool list a per-request artifact, and makes a revoked grant look to the model like something worth retrying.
- list_scoped_tools issues tools for one identifier at a time. Six of Clay's sixteen tools reach the model, each already bound to that rep's connected account, and no Clay token enters agent code or model context.
The list came back with eleven accounts. The rep who asked for it owns a hundred and forty. The eleven belonged to the GTM ops lead who had wired up the Clay connection nine weeks earlier and then stopped thinking about it. Nothing errored, nothing retried, nothing appeared in Sentry. The agent had called claymcp_query_objects with onlyMine set to true, exactly as its prompt instructed, and Clay had answered the question honestly on behalf of the caller it was handed. Three days later the whole team was hard-blocked on the 12th of the month, because every run since launch had drawn down a single credit budget.
An ICP description is a fan-out over an async task graph, not a query
The Clay MCP connector exposes sixteen tools. None of them accepts a paragraph of ICP prose and returns a list of accounts. What exists is a set of narrow entry points that a reasoning loop has to sequence itself.
Resolve the ICP against Clay Audiences
query, onlyMine, audienceName, limit
claymcp_find_and_enrich_company
Pull in a net-new account Audiences did not return
companyIdentifier (domain or LinkedIn URL)
Returns a taskId; enrichment settles after
claymcp_add_company_data_points
Attach the ICP's qualifying signals to that search
Read entities, enrichment values, and statuses back
Immediate read of current state
claymcp_find_and_enrich_contacts_at_company
Build the buying committee at a qualified account
companyIdentifier, contactFilters
Returns a taskId; settles after
claymcp_get_credits_available
Four properties of that table drive every design decision downstream.
- The account set precedes the people search. claymcp_find_and_enrich_contacts_at_company takes a companyIdentifier, so contacts cannot be found until the accounts are resolved.
- Enrichment is a fan-out of single-entity calls. claymcp_find_and_enrich_company handles one domain per call, so a 200-domain ICP is 200 calls unless you cap it.
- Nothing blocks. Search and enrichment hand back a taskId and fill in afterwards, so the model's first read of claymcp_get_task_context will usually be empty.
- Every tool requires a rationale string. That is a schema-level requirement, and it is the most useful audit artifact in the whole pipeline; the model is forced to state intent on every call.
Why the identity on the Clay connection changes the answer, not just the permission
This is the part that separates Clay from a generic REST connector. Clay's MCP is administered per rep, not per workspace. In Clay's MCP settings documentation, workspace admins invite reps individually, assign them a Sales Rep permission type that blocks the Clay web app entirely, set a monthly credit budget per user, and choose which Functions are callable from an AI tool at all. On Enterprise, a background sync matches each MCP user to the Salesforce accounts they own, and a separate toggle decides whether reps may query beyond their own book.
Every one of those controls keys off the identity that completed the OAuth flow. Which means a shared credential does not produce a permissions error. It produces a plausible, well-formed, wrong answer.
Per-rep connected account
One shared workspace credential
Salesforce account ownership (onlyMine)
Resolves to the rep's own book
Resolves to whoever holds the shared grant; the filter returns the wrong book and does not error
Draws down that rep's budget; a runaway run affects one person
Draws down one pooled budget; a runaway run hard-blocks the whole team until the 1st of the month at 00:00 UTC
Functions enabled for MCP
claymcp_list_subroutines returns the ops-approved set for that rep
Returns whatever the shared identity can see, regardless of who asked
Sales Rep permission type
Enforced at Clay's edge, per rep
Not enforced; the shared identity's reach applies to every caller
delete_connected_account cuts one rep off immediately
Cutting one rep off means rotating the credential every rep depends on
Two further details are worth holding onto, because both are documented failure modes rather than theory.
Clay's docs state plainly that admins cannot revoke a rep's MCP OAuth grant from the MCP users page; the levers are removing the person from the workspace or setting their credit limit to zero. Your connected-account layer is therefore not a convenience wrapper over Clay's controls. For revocation, it is the only control surface with the right granularity.
Clay also documents reps landing in their personal Clay workspace when they run the connection flow before accepting the company invite. The tenant is chosen by the human at consent time, not asserted by your application. That is why the redirect handler has to verify the binding instead of assuming it.
Recommended reading: Access Control for Multi-Tenant AI Agents covers the tenant-boundary version of this failure, and Why Admin Accounts Are the Wrong Model for AI Agents covers the shared-credential version.
Bind the connected account before the model sees a tool
The connected account is the object that makes "this rep's Clay" a first-class thing in your system. It is created once per rep per tenant, it holds the vaulted grant, and it carries the status your runtime gates on.
# clay_identity.py
import os
from scalekit import ScalekitClient
from scalekit.common.exceptions import ScalekitNotFoundException
from scalekit.v1.connected_accounts.connected_accounts_pb2 import ACTIVE
# This string must match the connection name in the Scalekit dashboard exactly
# (AgentKit > Connections > Create Connection). A mismatch does not fail at
# startup; it surfaces as a not-found on the first tool call.
CLAY_CONNECTION = "claymcp"
scalekit = ScalekitClient(
env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"],
client_id=os.environ["SCALEKIT_CLIENT_ID"],
client_secret=os.environ["SCALEKIT_CLIENT_SECRET"],
)
actions = scalekit.actions
def start_clay_authorization(identifier: str, organization_id: str, verify_url: str) -> str:
"""Create this rep's connected account and return their Clay consent link.
`identifier` is YOUR user id, resolved server side after you authenticate the
request. It is never accepted from the client; it is the key that lets your
agent act as this specific rep.
"""
actions.get_or_create_connected_account(
connection_name=CLAY_CONNECTION,
identifier=identifier,
organization_id=organization_id, # the tenant this account belongs to
user_id=identifier, # your app's user id, for audit joins
)
magic_link = actions.get_authorization_link(
connection_name=CLAY_CONNECTION,
identifier=identifier,
user_verify_url=verify_url, # your redirect handler
state=organization_id, # echoed back so you can re-establish tenant
)
return magic_link.link
def complete_clay_authorization(auth_request_id: str, identifier: str) -> int:
"""Run this in the redirect handler.
Clay lets a rep pick which workspace to grant against, so the binding is a
fact you confirm after the fact, not one you assert up front.
"""
actions.verify_connected_account_user(
auth_request_id=auth_request_id,
identifier=identifier,
)
# `_details` returns metadata only. The sibling method `get_connected_account`
# returns the credential itself, which nothing in an agent runtime needs.
details = actions.get_connected_account_details(
connection_name=CLAY_CONNECTION,
identifier=identifier,
)
return details.connected_account.status
def clay_preflight(identifier: str) -> tuple[bool, str]:
"""Deterministic gate, run before a single model token is spent."""
try:
details = actions.get_connected_account_details(
connection_name=CLAY_CONNECTION,
identifier=identifier,
)
except ScalekitNotFoundException:
return False, "no_connected_account"
# ConnectorStatus: ACTIVE, EXPIRED, PENDING_AUTH, PENDING_VERIFICATION, DISCONNECTED
if details.connected_account.status != ACTIVE:
return False, f"connected_account_status={details.connected_account.status}"
# Clay hard-blocks a rep once their monthly MCP credit limit is reached.
# Find that out here, not four enrichment calls into the run.
credits = actions.execute_tool(
tool_input={"rationale": "Pre-run budget check for the target account list builder"},
tool_name="claymcp_get_credits_available",
identifier=identifier,
)
return True, str(credits.data)
The scoped tool surface: six of sixteen, chosen per connected account
Before any code runs, be precise about what is happening here. The agent is not loading a Clay tool catalog. actions.langchain.get_tools calls list_scoped_tools under the hood for a single identifier, and returns only the tools that identifier's connected account is authorized to call, with the connected_account_id already closed over inside each StructuredTool. The rep's identity is resolved once, at tool-construction time. It is never a model-supplied argument, and it is never something a prompt injection can change.
Filtering further, from sixteen tools down to six, buys two things. At roughly 200 tokens per definition, it drops about 2,000 tokens of context off every turn. More importantly it removes a documented selection hazard: Clay's own docs warn that Functions whose names overlap built-in tool names cause the AI tool to invoke the default instead. Surface reduction is the lever here. Model upgrades help; they are not the lever.
# clay_tools.py
from clay_identity import CLAY_CONNECTION, actions
# Six of the sixteen tools the Clay MCP connector exposes. Subroutine execution,
# account Q&A, contact data points, and event tracking are not this agent's job,
# so they never enter the model's decision space.
LIST_BUILDER_TOOLS = [
"claymcp_get_credits_available",
"claymcp_query_objects",
"claymcp_find_and_enrich_company",
"claymcp_add_company_data_points",
"claymcp_find_and_enrich_contacts_at_company",
"claymcp_get_task_context",
]
def clay_tools_for(identifier: str):
"""LangChain StructuredTools with this rep's connected account bound in.
Nothing about the credential, the connected account id, or the Clay
workspace reaches the model. The tool call carries arguments; Scalekit
injects the grant server side and returns structured output.
"""
return actions.langchain.get_tools(
identifier=identifier,
connection_names=[CLAY_CONNECTION],
tool_names=LIST_BUILDER_TOOLS,
page_size=50,
)
That closure is also why the tool list cannot be a module-level constant. This is the conclusion the LangChain community reached independently in the langserve discussion on passing auth tokens to tools without the LLM, where the recommendation was to instantiate the agent at run time with auth bound to the tools, and again in the deepagents discussion on multi-tenant skills and subagents, where the working pattern is an agent factory per tenant. Both are right about the shape. Neither cache should be keyed on tenant here, because the binding is per connected account and it goes stale the moment a grant is revoked.
The agent loop, and the three things the adapter will not do for you
The Scalekit LangChain adapter gets you correctly scoped tools. It does not get you a correct agent. Three of its behaviours matter in production.
Tool failures arrive as ordinary tool output
The adapter catches every exception and returns the string Error executing tool {name}: {e} as the tool result
A wrap_tool_call guard that inspects content and halts, instead of letting a ReAct loop retry a revoked grant
No client-side argument validation
args_schema is the raw JSON Schema dict from the connector, not a Pydantic model
Treat a malformed dataPoints array as a Clay-side failure that still counts against the run's enrichment budget
Tools are bound to a connected account, not a process
connected_account_id is captured in each tool's closure at construction
Build tools inside the request; cache the compiled graph if you must, never the tools
All three are handled with wrap_tool_call middleware, which receives a ToolCallRequest and a handler it may call zero times (to short-circuit) or many times (to poll and retry).
# clay_middleware.py
import time
from dataclasses import dataclass, field
from langchain.agents.middleware import wrap_tool_call
from langchain_core.messages import ToolMessage
# The adapter surfaces failures as tool text with this prefix, not as exceptions.
ADAPTER_ERROR_PREFIX = "Error executing tool"
# Tools that trigger Clay enrichment and therefore draw down the rep's monthly
# credit budget. This classification is your policy; the connector does not
# declare it, so keep it next to the code that enforces it.
SPENDING_TOOLS = {
"claymcp_find_and_enrich_company",
"claymcp_add_company_data_points",
"claymcp_find_and_enrich_contacts_at_company",
}
@dataclass
class RunGuard:
"""Per-run state. Constructed alongside the tools, never shared across reps."""
identifier: str
max_spending_calls: int = 25
spending_calls: int = 0
halted_reason: str | None = None
audit: list[dict] = field(default_factory=list)
def budget_middleware(guard: RunGuard):
"""Outermost. A prompt is not a spend control; this is."""
@wrap_tool_call
def _budget(request, handler):
name = request.tool_call["name"]
if guard.halted_reason:
return ToolMessage(
content=f"Run halted: {guard.halted_reason}. No further Clay calls.",
tool_call_id=request.tool_call["id"],
status="error",
)
if name in SPENDING_TOOLS:
if guard.spending_calls >= guard.max_spending_calls:
# handler is never called, so no credits move.
return ToolMessage(
content=(
f"Enrichment budget for this run is exhausted after "
f"{guard.max_spending_calls} calls. Summarise the accounts "
f"already qualified and stop."
),
tool_call_id=request.tool_call["id"],
status="error",
)
guard.spending_calls += 1
result = handler(request)
content = str(getattr(result, "content", ""))
# Clay requires a rationale on every tool. Log it next to identity and
# outcome; it is the only record of why the agent thought this was right.
guard.audit.append(
{
"identifier": guard.identifier,
"tool": name,
"rationale": request.tool_call["args"].get("rationale"),
"ok": not content.startswith(ADAPTER_ERROR_PREFIX),
}
)
return result
return _budget
def auth_halt_middleware(guard: RunGuard):
"""A revoked or expired Clay grant arrives as tool text, not an exception.
Left alone, the loop reads it, apologises, and calls the same tool again.
"""
auth_markers = ("unauthorized", "401", "invalid_token", "forbidden", "403")
@wrap_tool_call
def _halt(request, handler):
result = handler(request)
content = str(getattr(result, "content", ""))
if content.startswith(ADAPTER_ERROR_PREFIX) and any(
marker in content.lower() for marker in auth_markers
):
guard.halted_reason = "clay_grant_no_longer_valid"
return ToolMessage(
content=(
"The Clay connection for this user is no longer valid. Stop and "
"report that the rep must reconnect Clay."
),
tool_call_id=request.tool_call["id"],
status="error",
)
return result
return _halt
def settle_middleware(poll_delays=(2, 4, 8, 16)):
"""Innermost. Clay searches return a taskId and fill in afterwards.
Someone has to poll. Doing it here costs four HTTP round trips; doing it in
the reasoning loop costs four model turns and invites the model to re-run the
enrichment because "nothing came back".
"""
@wrap_tool_call
def _settle(request, handler):
if request.tool_call["name"] != "claymcp_get_task_context":
return handler(request)
result = handler(request)
previous = None
for delay in poll_delays:
current = str(getattr(result, "content", ""))
if current.startswith(ADAPTER_ERROR_PREFIX):
return result
# Settle on response stability rather than a hardcoded status
# vocabulary; those strings are connector specific and will drift.
if current == previous:
break
previous = current
time.sleep(delay)
result = handler(request)
return result
return _settle
Ordering matters. Middleware composes with the first entry as the outermost layer, so the budget guard decides whether to spend before the settle loop can issue a single request, and one budgeted enrichment call covers the whole polling sequence beneath it.
# clay_agent.py
import os
from langchain.agents import create_agent
from clay_identity import clay_preflight
from clay_middleware import (
RunGuard,
auth_halt_middleware,
budget_middleware,
settle_middleware,
)
from clay_tools import clay_tools_for
MODEL = os.environ["LIST_BUILDER_MODEL"] # e.g. "anthropic:claude-sonnet-4-5"
SYSTEM_PROMPT = """You build target account lists in Clay for one sales rep.
Work in this order:
1. Turn the ICP into a natural-language filter and call claymcp_query_objects
to pull matching accounts from Clay Audiences. Set onlyMine to true unless
the rep explicitly asks for accounts outside their own book.
2. For each domain the ICP names that Audiences did not return, call
claymcp_find_and_enrich_company once. Keep the returned taskId.
3. Attach the ICP's qualifying signals with claymcp_add_company_data_points
against that taskId. Batch all data points into one call per task.
4. Read results back with claymcp_get_task_context before judging any account.
5. Only for accounts that clear the ICP, call
claymcp_find_and_enrich_contacts_at_company with contactFilters for the
buying committee titles.
Every tool takes a rationale. Write it for a human auditor reading the log six
weeks from now.
Never claim an account qualifies on a data point you have not read back from
claymcp_get_task_context. If a tool returns an error, do not call it again."""
def build_list_builder(identifier: str, max_spending_calls: int = 25):
ok, detail = clay_preflight(identifier)
if not ok:
raise PermissionError(f"Clay is not callable for {identifier}: {detail}")
guard = RunGuard(identifier=identifier, max_spending_calls=max_spending_calls)
agent = create_agent(
model=MODEL,
# Built inside the request. These tools carry this rep's connected
# account and must not outlive it.
tools=clay_tools_for(identifier),
system_prompt=SYSTEM_PROMPT,
middleware=[
budget_middleware(guard), # outermost: refuses before spend
auth_halt_middleware(guard),
settle_middleware(), # innermost: owns the poll loop
],
)
return agent, guard
def run(identifier: str, icp: str) -> dict:
agent, guard = build_list_builder(identifier)
result = agent.invoke(
{
"messages": [
{
"role": "user",
"content": (
"Build a target account list from this ICP, then find the "
f"buying committee at each account that qualifies.\n\nICP: {icp}"
),
}
]
}
)
return {
"answer": result["messages"][-1].content,
"enrichment_calls": guard.spending_calls,
"halted": guard.halted_reason,
"audit": guard.audit,
}
if __name__ == "__main__":
out = run(
identifier="rep_4471", # your user id, resolved server side, never from the client
icp=(
"Series B and Series C B2B SaaS companies in North America, 200 to 800 "
"employees, running Snowflake, that have posted a data engineering role "
"in the last 90 days."
),
)
print(out["answer"])
print(f"enrichment calls: {out['enrichment_calls']} | halted: {out['halted']}")
What the run log has to answer when the rep says the list is wrong
The eleven-account list did not fail. That is the whole problem with it. Debugging it requires a log that can distinguish three things a generic tool-call trace collapses into one.
- Which rep the call was made as. That is identifier plus the connected_account_id Scalekit resolved, and it is what tells you onlyMine was answering for the ops lead.
- Why the agent made the call. That is Clay's mandatory rationale, which is the model's own stated intent and the only artifact that distinguishes "the model chose badly" from "the model chose well against bad data".
- What actually happened. That is the execution_id Scalekit returns on every execute_tool response, which the adapter folds into the tool result so it lands in the transcript alongside everything else.
Those three, joined on the connected account, answer "what did the agent do on behalf of rep X on Tuesday" as one filtered query instead of a three-week investigation. Audit Trails for Agent Auth in B2B SaaS covers the event taxonomy in full, and Agent Tool Observability covers separating connector errors from infrastructure errors at the point they occur, which is exactly the distinction the halt middleware depends on.
The tradeoff is real and worth naming. Per-rep connected accounts mean every rep runs a consent flow, every rep carries their own credit ceiling, and a rep who never connects Clay has an agent that cannot run for them at all. A shared credential avoids all of that. It avoids it by making one arbitrary rep's Salesforce book the answer to every question, and by pooling a budget that any single run can exhaust.
Next steps to start building the list builder
- Create the connection. In the Scalekit dashboard, go to AgentKit > Connections > Create Connection and add claymcp. Copy SCALEKIT_ENVIRONMENT_URL, SCALEKIT_CLIENT_ID, and SCALEKIT_CLIENT_SECRET from Developers > API Credentials, and confirm the connection name matches CLAY_CONNECTION character for character.
- Get Clay's side right first. In Clay, invite reps under the Sales Rep permission type, set a default MCP credit limit before anyone connects, and enable the Functions you want reachable. On Enterprise, turn on audience user-ID sync so onlyMine has ownership data to resolve against.
- Install and wire it up: pip install scalekit-sdk-python langchain, then run start_clay_authorization for one rep and complete the flow end to end. Confirm clay_preflight returns ACTIVE before writing agent code.
- Verify the scoped surface. Call clay_tools_for(identifier) and assert you get six tools back, not sixteen and not zero. Zero means the connected account is not ACTIVE.
- Extend to ops-built Functions by adding claymcp_list_subroutines and claymcp_run_subroutine_no_mapping to LIST_BUILDER_TOOLS. Check the returned Function names for collisions with Clay's built-in tool names first.
- Add revocation and expiry handling with agent webhooks so a disconnected Clay account pauses the agent rather than failing mid-run. How to Handle Token Refresh for AI Agents covers the failure modes worth alerting on.
Adjacent builds worth reading before you extend this one: the outbound prospecting agent walkthrough for pushing the finished list into a sequencer, the CRM AI Agent template for writing qualified accounts back, and How Tool Calling Auth Changes When You Move from Single-Tenant to Multi-Tenant for the identifier-handling patterns applied to a second connector.
FAQs
Can this run headless on a schedule, or does a rep have to be present?
Headless is fine; the grant belongs to a rep and persists in the vault, so the agent acts as them without them being online. What must not be headless is failure. Gate every scheduled run on clay_preflight, and treat EXPIRED, DISCONNECTED, or PENDING_AUTH as a hard stop that notifies the rep, not as something to retry.
Why not point LangChain's MCP adapter straight at Clay's MCP endpoint?
You can, and for a single-user local agent that is the simpler path. It breaks for a multi-user product because Clay MCP uses OAuth 2.1 with dynamic client registration, and a direct MCP client gives you one client registration and one token per process. Per-end-user tool-level authorization is still an open design question in the protocol itself; see the MCP multi-user authorization discussion. Agent Workflows with Remote MCP Servers covers the split in more depth.
Should I cache the agent per user to avoid rebuilding tools on every request?
Cache the compiled graph keyed on the identifier plus the tool-name set, and invalidate it on any connected-account status change. Do not cache the StructuredTool objects themselves across a revocation; the connected_account_id is inside the closure, so a cached tool will keep pointing at a grant you have already deleted.
What happens mid-run when a rep hits their Clay credit ceiling?
Clay hard-blocks further actions until the monthly reset on the 1st at 00:00 UTC. The tool call returns as adapter error text rather than an exception, so without the halt middleware the loop will read it and try again. With it, the run stops once and reports a reason a human can act on. Admins can raise the per-user limit to restore access immediately.
Is onlyMine enough for tenant isolation between my customers?
No. onlyMine is a filter inside one Clay workspace, scoped to Salesforce ownership. Isolation between your own customers is the connected account plus organization_id in your auth layer, which is what keeps customer A's Clay grant unreachable from customer B's agent run. That is a data model property, not a policy rule layered on top.
Where does the rationale string actually go?
It is a required input on every Clay MCP tool and travels with the call. Capturing it in the middleware, as budget_middleware does, is what lets you reconstruct the model's stated intent later. Treat it as model output rather than ground truth; it tells you what the agent believed it was doing, which is precisely the thing missing when a list looks correct and is not.