Cold Leads

Nástroje Cold Leads pro agenty CrewAI a LangChain

Pro koho
Vývojáři v Pythonu, kteří staví agenty s CrewAI nebo LangChain
Problém
Agent, po kterém chcete kontaktní údaje, bude hádat a uhodnutá adresa vypadá přesně jako skutečná. Bez ověřovacího nástroje nepozná rozdíl ani agent, ani člověk, který čte jeho výstup.
Řešení
Dejte agentovi tři úzce vymezené nástroje nad API Cold Leads: hledání ve vlastním CRM uživatele, ověření adresy a odhad adresy ze jména a domény s poctivým označením metody. Chyby se vracejí jako data, takže agent může reagovat, místo aby spadl.
Co získáte
Sdílený API klient, třídy nástrojů pro CrewAI a pro LangChain a minimální crew a agent, které je používají.

Adresy, identifikátory a výsledky v příkladech jsou ilustrativní. Doména example.com je vyhrazená pro dokumentaci, takže skutečná kontrola těchto adres vrátí invalid.

Ukázky kódu jsou ve všech jazycích stejné, komentáře v nich jsou anglicky.

Tři nástroje

NástrojEndpointCenaCo agent dostane
search_crm_contactsGET /api/v1/leadsZdarmaKontakty na doméně firmy, které už máte v CRM Cold Leads (až 50), s fází, štítky, ověřením a do_not_contact.
verify_emailPOST /api/v1/verify1 kreditstatus, score, reasons, mx, catch_all, disposable a role.
find_emailPOST /api/v1/find1 kreditNejpravděpodobnější adresu, method (verified, pattern nebo none), confidence a candidates.

Cold Leads nemá databázi lidí ani firem. search_crm_contacts nedokáže objevit nové potenciální zákazníky; vrací jen kontakty, které uživatel už má. Napište to do popisu nástroje, jinak bude model očekávat něco jiného.

Sdílený klient

Oba frameworky používají stejného malého klienta. Každou odpověď vrací jako JSON řetězec a chyby API (no_credits, rate_limited, bad_api_key) místo vyhození výjimky převádí na data, která model umí přečíst.

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)
  • Každý nástroj dědí z crewai.tools.BaseTool a má name, description, args_schema (model Pydantic) a _run. env_vars deklaruje klíč, který nástroj potřebuje; samotný klient čte COLDLEADS_API_KEY z prostředí.
  • Nástroje se agentovi předávají jako instance a {domain} a {role} v úloze se doplní z kickoff(inputs=...).
  • CrewAI potřebuje Python 3.10 až 3.13. Řádek llm používá Claude Opus 5.5; funguje jakýkoli model s voláním nástrojů, který CrewAI podporuje.

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"))
  • Nástroje používají dekorátor @tool z langchain.tools s explicitním name, description a args_schema.
  • Smyčku volání nástrojů řídí create_agent z langchain.agents. Nahrazuje create_react_agent z LangGraphu, který je označen jako zastaralý.
  • Model se zadává jako provider:model. Poslední zpráva může obsahovat několik bloků obsahu, proto příklad vypisuje jen její textové bloky.

Popisy, podle kterých model dokáže jednat

  • Uveďte v popisu cenu (1 kredit za verify_email a find_email, hledání v CRM zdarma), aby model věděl, která volání spotřebovávají kredity.
  • Vysvětlete poslední kód důvodu: ok znamená, že poštovní server schránku přijal, smtp_unreachable, že se schránka nekontrolovala, catch_all, že doména přijímá jakoukoli adresu.
  • Napište, že výsledek find_email s method pattern je odhad a že kontaktům s do_not_contact true se nesmí posílat e-maily.
  • Držte nástroje úzce vymezené. Agent, který umí jen hledat, ověřovat a odhadovat, nemůže omylem poslat e-mail ani změnit záznamy.

Limity a náklady

  • verify_email a find_email stojí 1 kredit za volání, i u výsledků z 30denní cache; hledání v CRM je zdarma.
  • Klient žádá o časový limit ověření 10 sekund; když vyprší, výsledek je risky s důvodem timeout a do cache se neukládá.
  • 120 požadavků za minutu na klíč. Smyčka agenta, která kontroluje mnoho adres, na tento limit může narazit; chyba rate_limited, kterou pak dostane, obsahuje retry_after_seconds.
  • API je součástí tarifu Business: $99 měsíčně s 10 000 kredity měsíčně; další balíčky po 1 000 kreditech stojí $5.

Otázky

Najde agent ve firmě nové potenciální zákazníky?

Ne. Hledání v CRM vrací jen kontakty, které už jsou ve vašem pracovním prostoru Cold Leads, a dohledání adresy potřebuje jméno osoby. Cold Leads data o lidech neposkytuje.

Proč nástroje chyby vracejí, místo aby vyhazovaly výjimky?

Výjimka uvnitř nástroje může běh agenta ukončit. Chyba jako no_credits nebo rate_limited vrácená jako JSON se dostane k modelu, který ji může nahlásit nebo počkat, místo aby selhal.

Jde to i bez psaní nástrojů?

Ano, pro ověřování a hledání v CRM: MCP server Cold Leads zpřístupňuje search_leads a verify_email libovolnému MCP klientovi. Nástroj pro dohledání adresy nemá.