Cold Leads tools for CrewAI and LangChain agents
- Who it is for
- Python developers building agents with CrewAI or LangChain
- The problem
- An agent asked for contact details will guess, and a guessed address looks exactly like a real one. Without a verification tool, neither the agent nor the person reading its output can tell the difference.
- The solution
- Give the agent three narrow tools backed by the Cold Leads API: search the user's own CRM, verify an address, and guess an address from a name and a domain with an honest method label. Errors come back as data, so the agent can react instead of crashing.
- What you get
- A shared API client, tool classes for CrewAI and for LangChain, and a minimal crew and agent that use them.
Addresses, IDs and results in the examples are illustrative. example.com is reserved for documentation, so a real check of these addresses returns invalid.
The three tools
| Tool | Endpoint | Cost | What the agent gets |
|---|---|---|---|
| search_crm_contacts | GET /api/v1/leads | Free | Contacts already in your Cold Leads CRM at a company domain (up to 50), with stage, tags, verification and do_not_contact. |
| verify_email | POST /api/v1/verify | 1 credit | status, score, reasons, mx, catch_all, disposable and role. |
| find_email | POST /api/v1/find | 1 credit | The most likely address, method (verified, pattern or none), confidence and candidates. |
Cold Leads has no people or company database. search_crm_contacts cannot discover new prospects; it only returns contacts the user already has. Say so in the tool description, or the model will expect otherwise.
A shared client
Both frameworks use the same small client. It returns every answer as a JSON string and turns API errors (no_credits, rate_limited, bad_api_key) into data the model can read instead of raising.
# coldleads_tools.py - shared by the CrewAI and LangChain examples (pip install httpx)
import json
import os
import httpx
VERIFY_DESC = (
"Verify one e-mail address with Cold Leads (costs 1 credit). Returns status valid, risky or invalid, "
"a score from 0 to 100 and reason codes. The last reason says what was checked: ok means a mail server "
"accepted the mailbox, smtp_unreachable means the mailbox itself was not checked, catch_all means the "
"domain accepts any address, mailbox_missing, no_mx and syntax mean the address is invalid."
)
FIND_DESC = (
"Guess a person's work e-mail address from first name, last name and company domain (costs 1 credit). "
"Returns the most likely address, method (verified only when a mail server confirmed the mailbox, "
"otherwise pattern), confidence from 0 to 100 and candidates. Treat a pattern result as unconfirmed."
)
SEARCH_DESC = (
"Search the contacts already saved in the user's own Cold Leads CRM for a company domain (free). "
"role is an optional keyword matched against name, e-mail local part, tags, notes and contact type. "
"This is not a people database: it only returns contacts the user already has. "
"Never e-mail contacts whose do_not_contact is true."
)
class ColdLeadsClient:
def __init__(self, api_key=None, base_url="https://coldleads.app/api/v1"):
self.http = httpx.Client(
base_url=base_url,
headers={"x-api-key": api_key or os.environ["COLDLEADS_API_KEY"]},
timeout=40.0,
)
def _call(self, method, path, **kwargs) -> str:
r = self.http.request(method, path, **kwargs)
try:
data = r.json()
except ValueError:
data = {}
if r.status_code >= 400:
# hand API errors (no_credits, rate_limited with retry_after_seconds, ...) to the model as data
return json.dumps({"http_status": r.status_code, "error": f"http_{r.status_code}", **data})
return json.dumps(data)
def verify(self, email: str) -> str:
return self._call("POST", "/verify", json={"email": email, "timeout_ms": 10000})
def find(self, first: str, last: str, domain: str) -> str:
return self._call("POST", "/find", json={"first": first, "last": last, "domain": domain})
def search_crm(self, domain: str, role: str = "", limit: int = 10) -> str:
params = {"domain": domain, "limit": limit}
if role:
params["role"] = role
return self._call("GET", "/leads", params=params)CrewAI
# pip install crewai httpx (CrewAI needs Python 3.10-3.13)
from crewai import LLM, Agent, Crew, Task
from crewai.tools import BaseTool, EnvVar
from pydantic import BaseModel, Field
from coldleads_tools import FIND_DESC, SEARCH_DESC, VERIFY_DESC, ColdLeadsClient
client = ColdLeadsClient()
KEY = [EnvVar(name="COLDLEADS_API_KEY", description="Cold Leads secret API key (sk_...)", required=True)]
class VerifyInput(BaseModel):
email: str = Field(..., description="E-mail address to verify, e.g. anna@example.com")
class FindInput(BaseModel):
first: str = Field(..., description="First name")
last: str = Field(..., description="Last name")
domain: str = Field(..., description="Company domain, e.g. example.com")
class SearchInput(BaseModel):
domain: str = Field(..., description="Company domain, e.g. example.com")
role: str = Field("", description="Optional keyword such as ceo, sales or marketing")
limit: int = Field(10, ge=1, le=50, description="Maximum number of contacts")
class VerifyEmailTool(BaseTool):
name: str = "verify_email"
description: str = VERIFY_DESC
args_schema: type[BaseModel] = VerifyInput
env_vars: list[EnvVar] = KEY
def _run(self, email: str) -> str:
return client.verify(email)
class FindEmailTool(BaseTool):
name: str = "find_email"
description: str = FIND_DESC
args_schema: type[BaseModel] = FindInput
env_vars: list[EnvVar] = KEY
def _run(self, first: str, last: str, domain: str) -> str:
return client.find(first, last, domain)
class SearchCrmTool(BaseTool):
name: str = "search_crm_contacts"
description: str = SEARCH_DESC
args_schema: type[BaseModel] = SearchInput
env_vars: list[EnvVar] = KEY
def _run(self, domain: str, role: str = "", limit: int = 10) -> str:
return client.search_crm(domain, role, limit)
researcher = Agent(
role="B2B outreach researcher",
goal="Prepare a short list of contacts whose addresses were checked with Cold Leads",
backstory="You never invent contacts or addresses and you report what each check actually confirmed.",
tools=[SearchCrmTool(), VerifyEmailTool(), FindEmailTool()],
llm=LLM(model="anthropic/claude-opus-5-5", max_tokens=4096), # any tool-calling model CrewAI supports
)
task = Task(
description=(
"Find the contacts at {domain} in my Cold Leads CRM whose role keyword matches {role}. "
"Skip anyone with do_not_contact true, verify the others and report status and the last reason code."
),
expected_output="A table with e-mail, name, status, score and last reason code.",
agent=researcher,
)
if __name__ == "__main__":
result = Crew(agents=[researcher], tasks=[task]).kickoff(inputs={"domain": "example.com", "role": "cto"})
print(result.raw)- Each tool subclasses crewai.tools.BaseTool with name, description, args_schema (a Pydantic model) and _run. env_vars declares the key the tool needs; the client itself reads COLDLEADS_API_KEY from the environment.
- Tools are passed to the agent as instances, and {domain} and {role} in the task are filled from kickoff(inputs=...).
- CrewAI needs Python 3.10 to 3.13. The llm line uses Claude Opus 5.5; any model CrewAI supports with tool calling works.
LangChain
# pip install -U "langchain[anthropic]" httpx (LangChain 1.x, Python 3.10+)
from langchain.agents import create_agent
from langchain.tools import tool
from pydantic import BaseModel, Field
from coldleads_tools import FIND_DESC, SEARCH_DESC, VERIFY_DESC, ColdLeadsClient
client = ColdLeadsClient()
class VerifyInput(BaseModel):
email: str = Field(description="E-mail address to verify, e.g. anna@example.com")
class FindInput(BaseModel):
first: str = Field(description="First name")
last: str = Field(description="Last name")
domain: str = Field(description="Company domain, e.g. example.com")
class SearchInput(BaseModel):
domain: str = Field(description="Company domain, e.g. example.com")
role: str = Field(default="", description="Optional keyword such as ceo, sales or marketing")
limit: int = Field(default=10, ge=1, le=50, description="Maximum number of contacts")
@tool("verify_email", description=VERIFY_DESC, args_schema=VerifyInput)
def verify_email(email: str) -> str:
return client.verify(email)
@tool("find_email", description=FIND_DESC, args_schema=FindInput)
def find_email(first: str, last: str, domain: str) -> str:
return client.find(first, last, domain)
@tool("search_crm_contacts", description=SEARCH_DESC, args_schema=SearchInput)
def search_crm_contacts(domain: str, role: str = "", limit: int = 10) -> str:
return client.search_crm(domain, role, limit)
agent = create_agent(
model="anthropic:claude-opus-5-5", # any tool-calling chat model in provider:model form
tools=[search_crm_contacts, verify_email, find_email],
system_prompt=(
"You help with B2B outreach research. Use only the Cold Leads tools for contact data and never invent "
"addresses. For every verification, report the status and the last reason code."
),
)
if __name__ == "__main__":
result = agent.invoke({"messages": [{"role": "user", "content": "List my CRM contacts at example.com tagged cto and verify their addresses."}]})
final = result["messages"][-1]
print("".join(block["text"] for block in final.content_blocks if block["type"] == "text"))- The tools use the @tool decorator from langchain.tools with an explicit name, description and args_schema.
- create_agent from langchain.agents runs the tool loop. It replaces LangGraph's create_react_agent, which is deprecated.
- The model is given as provider:model. The final message may contain several content blocks, so the example prints only its text blocks.
Descriptions the model can act on
- State the cost in the description (1 credit for verify_email and find_email, free for the CRM search), so the model knows which calls spend credits.
- Explain the last reason code: ok means a mail server accepted the mailbox, smtp_unreachable means the mailbox was not checked, catch_all means the domain accepts any address.
- Say that a find_email result with method pattern is a guess, and that contacts with do_not_contact true must not be e-mailed.
- Keep the tools narrow. An agent that can only search, verify and guess cannot send e-mail or change records by mistake.
Limits and costs
- verify_email and find_email cost 1 credit per call, including results from the 30-day cache; the CRM search is free.
- The client asks for a 10-second verification budget; when it runs out, the result is risky with the reason timeout and is not cached.
- 120 requests per minute per key. An agent loop that checks many addresses may hit it; the rate_limited error it then receives includes retry_after_seconds.
- The API is part of the Business plan: $99 a month with 10,000 credits a month; extra packs of 1,000 credits cost $5.
FAQ
Can the agent find new prospects at a company?
No. The CRM search returns only contacts already in your Cold Leads workspace, and the finder needs a person's name. Cold Leads does not provide people data.
Why do the tools return errors instead of raising them?
An exception inside a tool can end the agent run. Returned as JSON, an error such as no_credits or rate_limited reaches the model, which can report it or wait instead of failing.
Is there a way to do this without writing tools?
Yes, for verification and CRM search: the Cold Leads MCP server exposes search_leads and verify_email to any MCP client. It has no finder tool.