Cold Leads

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

ToolEndpointCostWhat the agent gets
search_crm_contactsGET /api/v1/leadsFreeContacts already in your Cold Leads CRM at a company domain (up to 50), with stage, tags, verification and do_not_contact.
verify_emailPOST /api/v1/verify1 creditstatus, score, reasons, mx, catch_all, disposable and role.
find_emailPOST /api/v1/find1 creditThe 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
# 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

crewai_coldleads.py
# 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

langchain_coldleads.py
# 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.