Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@

## Unreleased

- SDK/CLI: contacts runs. `client.contacts_run` (sync and async) creates, lists, reads, continues and cancels a stepwise contacts run: `find_contacts`, `find_missing`, `review`, `find_emails`. `ContactsRunRequest` takes `companies` from a prompt (`max_companies` defaults to 500), a domain list or lookalike personas, plus `people` titles, `relevance`, `per_company`, `max_contacts` (keep the best-fit N contacts across all companies; once full, find missing searches no more companies and companies cut by the cap are not billed), `auto_stop` (`False` keeps every contact up to `per_company`, best fit first, instead of cutting at the relevance level), `find_missing` and `email_cap_usd`; a `step_by_step` run pauses with `status="waiting"` after each step until `continue_(run_id, ContinueRunRequest(generation=...))`. `email_cap_usd` is optional: it caps what `find_emails` spends, and without one the step runs until the account balance runs out. `wait(run_id, follow=True, email_cap_usd=...)` continues each step for you and stops at a failed step or, when neither the run nor the call has a cap, before `find_emails`; `rows(run_id)` pages through every row; `title_preview()` shows the titles a people spec matches. A stale `generation` or a failed step continued without `redo=True` is a 409 (`ValidationError`), and a run idle for 30 days answers 410. The CLI adds `contacts run create|list|get|continue|cancel|wait|title-preview`; `create` reads the request from `--params-file`, and `wait` takes `--follow` and `--email-cap-usd`. It needs an API release that serves `/contacts/run`.
- SDK/CLI: with `max_records` omitted, discover and the contacts endpoints now return, and bill, up to 500 records instead of 100; pass `max_records=100` to keep the old size. No SDK change; older versions get the new default too.
- SDK/CLI: discover, count and `bulk companies` gain `valid_mx` (`--valid-mx`) to exclude companies with no mail server, omitted unless set. It needs an API release that knows the field.
- SDK/CLI: `client.contacts_run` (sync and async) starts automatic validated contact delivery from prompt, domain or lookalike companies and `people.fit`. Requests support top-level `max_companies`, `per_company`, `relevance`, `email_cap_usd`, exclusions and `create_key`. Removed unreleased step mode, title chips, `start`, `max_contacts`, `auto_stop`, `find_missing`, continuation overrides and wait follow/cap flags. `wait()` polls until done, failed or cancelled. Responses include applied company filters, company metadata and event names, contact totals, progress, email states, skipped-no-MX counts and stop reasons; obsolete flat rows and internal step fields are removed. `rows()` pages company domains. CLI adds `contacts run create|list|get|events|cancel|wait`; live `continue` is companies-only, and `title-preview` remains a separate live route. Creation propagates typed `InsufficientBalanceError` for 403 INSUFFICIENT_BALANCE. No contacts_run deprecation window applies because this surface is unreleased.
- SDK/CLI: `FindEmailRequest.suggested_email` sends an address you think is right: it is verified first, and the right one is recovered if it is not proven. It must be at `domain` or the company's mail domain, else 422. It works on `email.find` and on each request of `email.find_batch`; the CLI's `email find` gains `--suggested-email`. `EmailJobResult` gains `outcome` (`valid`, `valid_catch_all`, `recovered`, `pattern_inferred`, `not_found`, `unknown`, open) and `email`, read from `job.status()` or batch results, and `EnumerationOutput` / `ValidationOutput` gain `method` (how the address was proven). All are optional and need an API release that sends them.
- SDK/CLI: `contacts.search`, `contacts.count` and `contacts.generate` are deprecated in favor of `client.contacts_run`; the API marks their endpoints deprecated with a 2026-12-31 sunset. They keep working and raise a `DeprecationWarning`; the CLI's `contacts search`, `count` and `generate` print a deprecation line on stderr after their output. `contacts.discover`, `lookup`, `match` and `bulk_match` are unchanged.
- SDK (note for maintainers): `/contacts/run*` is hidden from the platform's public OpenAPI schema, so its request models live in the hand-kept `discolike/_contacts_run_requests.py` (generated once from a local spec), its routes are stamped `openapi=False`, and `scripts/check_contract.py` checks the run response models only when the spec carries them. `suggested_email` reaches `_generated/requests.py` through `PENDING_PROPERTIES` until the dev spec has it; the regeneration also picks up the dev spec's new site languages.
- SDK/CLI: `contacts.search` and `contacts.generate` are deprecated in favor of `client.contacts_run`; the API marks their endpoints deprecated with a 2026-12-31 sunset. They keep working and raise a `DeprecationWarning`; the CLI's `contacts search` and `generate` print a deprecation line on stderr after their output. `contacts.discover`, `lookup`, `match` and `bulk_match` are unchanged.
- SDK (note for maintainers): Contacts run request models live in `discolike/_contacts_run_requests.py`, kept in sync with platform source; the routes retain `openapi=False` for older specs, and `scripts/check_contract.py` checks the run response models only when the spec carries them. Regenerate `_generated/requests.py` with `scripts/gen_requests.py --spec .dev-openapi.json` from the dev spec; `suggested_email` is now in that spec and removed from `PENDING_PROPERTIES`.
- SDK: sync and async transports emit a visible `DiscolikeDeprecationWarning`, a `DeprecationWarning` subclass, on every call whose response carries `Deprecation`. Warnings include `Sunset` and `Link` when present; header values are kept as text, including malformed values. Email submission, reattachment and polling no longer emit hardcoded deprecation warnings.
- SDK/CLI: `ExtractResult` drops `language`; the API no longer returns it from `/extract`.

## 0.6.2 (2026-10-21)

Expand Down
34 changes: 21 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `ap
| `client.discover()` / `client.count()` | Find lookalike companies by ICP text, phrases, tech stack, geo, and 40+ other filters |
| `client.companies` | Company profiles: firmographics, scores, growth, redirects, vendors, subsidiaries |
| `client.contacts` | Search, look up, match, and discover contacts at target companies |
| `client.contacts_run` | Find contacts in reviewable steps, then their emails under a spending cap |
| `client.contacts_run` | Automatically find and deliver validated contacts under a spending cap |
| `client.match` | Match company names (plus phone/city/state) to domains — single or bulk CSV |
| `client.append()` | Enrich a CSV of domains with DiscoLike datasets |
| `client.segment()` / `client.segment_file()` | Auto-segment a list of domains (comma-separated string or CSV upload) |
Expand Down Expand Up @@ -313,25 +313,33 @@ result = job.wait()

### Contacts runs

`client.contacts_run` finds people at companies in four steps: `find_contacts`, `find_missing` (web search at companies with no indexed contact), `review` and `find_emails`. A `step_by_step` run (the default) pauses with `status="waiting"` after each step; `continue_()` starts the next one. `find_emails` bills per result; an optional `email_cap_usd`, given at create or on the continue that enters it, caps that spend, and without one it runs until the account balance runs out.
`client.contacts_run` automatically finds and validates contacts, using database candidates before web fallback. `max_companies` counts companies delivering at least one validated contact; `per_company` caps validated contacts at each company. Set `people.fit` to describe the people you want and optionally `email_cap_usd` to cap delivery spend.

```python
from discolike.requests import ContactsRunRequest, ContinueRunRequest
from discolike.requests import ContactsRunRequest, ContactsRunEventsParams

run = client.contacts_run.create(
ContactsRunRequest.model_validate(
{
"companies": {"source": "prompt", "icp_text": "Independent dental clinics in Ohio"},
"people": {"titles": ["Practice Owner", "Office Manager"]},
}
)
ContactsRunRequest.model_validate({
"companies": {"source": "prompt", "icp_text": "Independent dental clinics in Ohio"},
"people": {"fit": "Practice owners and office managers"},
"max_companies": 50,
"per_company": 3,
"email_cap_usd": 20,
})
)
run = client.contacts_run.wait(run.id, follow=True) # Stops before find_emails: no cap given.
rows = client.contacts_run.rows(run.id) # Every row, paged for you.
run = client.contacts_run.continue_(run.id, ContinueRunRequest(generation=run.generation, email_cap_usd=20))
page = client.contacts_run.events(run.id, ContactsRunEventsParams(after=0))
run = client.contacts_run.wait(run.id) # Returns on done, failed or cancelled.
rows = client.contacts_run.rows(run.id) # Pages company domains, keeping their contacts together.
print(run.summary)
```

`companies` takes `{"source": "prompt", "icp_text": ...}` (up to `max_companies`, default 500), `{"source": "domains", "domains": [...]}` or `{"source": "lookalike", "persona_ids": [...]}`. `max_contacts` keeps only the best-fit N contacts across all companies (`per_company` still caps each one), and `auto_stop=False` turns off the relevance cut, keeping every contact up to `per_company`, best fit first; both stay off the wire unless set. `wait(follow=True)` also stops at a failed step, which you continue with `redo=True`. Every continue carries the run's current `generation`; a stale one is a 409 (`ValidationError`), and a run idle for 30 days answers 410. `contacts.search`, `contacts.count` and `contacts.generate` are deprecated in favor of contacts runs; their endpoints sunset on 2026-12-31.
`companies` accepts a prompt (`icp_text` and/or `filters`), domains (`source="domains", domains=[...]`) or lookalike seeds (`source="lookalike", persona_ids=[...]`). `max_companies` is a top-level field, default 500. Responses include `companies`, `companies_total`, `rows_total` and summary totals (`companies_delivered`, `validated_contacts`, `spent_usd`). Domains may be masked as `Company N`; treat them as display labels. Delivered email labels are `Validated` and `Validated (catch-all resolved)`. Events include `next_cursor` for the next call's `after`.

Runs start automatically. `cancel()` stops unfinished work; `wait()` only polls and a timeout stops local polling. `continue_()` remains available for companies runs and returns 409 for contacts runs. The separate `title_preview()` route remains live, but contacts runs take free-text people fit rather than title chips. A run idle for 30 days answers 410. A creation request exceeding the remaining balance raises `InsufficientBalanceError` (a `PlanAccessError` subclass).

CLI: `discolike contacts run create|list|get|events|cancel|wait|continue|title-preview`. Create reads `--params-file` plus optional `--email-cap-usd`; `wait` takes `--timeout`; `get --all-rows` fetches all contacts. `get --rows-offset` and `--rows-limit` count company domains for contacts runs. `continue` only advances companies runs and accepts `--generation` and `--redo`.

`contacts.search` and `contacts.generate` are deprecated in favor of contacts runs; their endpoints sunset on 2026-12-31.

### Contact generation without an LLM key

Expand Down
4 changes: 2 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ export DISCOLIKE_API_KEY="dl_..." # create one at https://app.discolike.com/ac
| [`icp_prompt_search.py`](icp_prompt_search.py) | Turns a plain-English ICP into a company list with similarity scores | `python examples/icp_prompt_search.py "Series A fintechs in Europe selling to SMBs" --country EU` |
| [`phrase_match_count.py`](phrase_match_count.py) | Counts sites whose text contains an exact phrase, then lists them | `python examples/phrase_match_count.py "SOC 2" --country US` |
| [`enrich_crm_export.py`](enrich_crm_export.py) | Adds firmographics (size, revenue, location, industry, business model) to every domain in a CSV | `python examples/enrich_crm_export.py accounts.csv --domain-column website --output enriched.csv` |
| [`contacts_at_results.py`](contacts_at_results.py) | Discovers companies for an ICP, then finds contacts at the top N filtered by seniority, department, or title | `python examples/contacts_at_results.py "B2B SaaS selling to sales teams" --top 10 --seniority executive --has-email` |
| [`contacts_at_results.py`](contacts_at_results.py) | Automatically finds and delivers validated contacts for an ICP and people fit | `python examples/contacts_at_results.py "B2B SaaS selling to sales teams" --top 10 --fit "Sales executives" --email-cap-usd 20` |
| [`agent_signup_to_first_search.py`](agent_signup_to_first_search.py) | Opens a DiscoLike account for a person from an agent, relays `next_step`, and runs a first search once `DISCOLIKE_API_KEY` is set | `python examples/agent_signup_to_first_search.py --email jane@acme.com --first-name Jane --last-name Doe` |
| [`discover_and_enrich.py`](discover_and_enrich.py) | Discovers companies for an ICP, then runs a DiscoGen research prompt over them (needs a BYOK LLM provider) | `python examples/discover_and_enrich.py --icp "Cybersecurity for SMBs" --country US --query "What is their pricing model?"` |
| [`find_emails_from_csv.py`](find_emails_from_csv.py) | Finds verified work emails for a CSV of first name, last name, domain in batches of 500; only status `found` bills | `python examples/find_emails_from_csv.py people.csv --output emails.csv` |
| [`match_crm_contacts.py`](match_crm_contacts.py) | Matches a messy CRM contact export to DiscoLike persona IDs with resumable checkpointing | `python examples/match_crm_contacts.py contacts.csv --output matched.csv` |
| [`cli_recipes.sh`](cli_recipes.sh) | The same searches as `discolike discover`, `discolike count`, `discolike contacts search`, and `discolike signup` one-liners, plus a `discolike bulk` volume pull | `bash examples/cli_recipes.sh` |
| [`cli_recipes.sh`](cli_recipes.sh) | The same searches as `discolike discover`, `discolike count`, `discolike contacts run`, and `discolike signup` one-liners, plus a `discolike bulk` volume pull | `bash examples/cli_recipes.sh` |

Every script prints `--help`. Employee ranges are `min,max` strings such as `51,200`; countries are ISO-2 codes or region aliases like `EU`, `DACH`, `APAC`.
14 changes: 9 additions & 5 deletions examples/cli_recipes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@ discolike discover --icp-prompt "Series A fintechs in Europe selling to SMBs" --
discolike count --phrase-match "SOC 2" --country US --format json
discolike discover --phrase-match "SOC 2" --country US --max-records 50 --format json | jq -r '.[].domain'

# contacts_at_results.py: discover companies, then executives at the first ten of them
discolike discover --icp-prompt "B2B SaaS companies selling to sales teams" --max-records 10 --format json \
| jq -r '.[].domain' \
| xargs -I{} printf -- '--domain %s ' {} \
| xargs discolike contacts search --seniority executive --has-email --format json
# contacts_at_results.py: automatic validated contacts for an ICP and people fit
cat > contacts-run.json <<'JSON'
{"companies":{"source":"prompt","icp_text":"B2B SaaS selling to sales teams"},"people":{"fit":"Sales executives"},"max_companies":10,"per_company":3}
JSON
discolike contacts run create --params-file contacts-run.json --email-cap-usd 20
# Use the returned run ID:
# discolike contacts run events RUN_ID --after 0
# discolike contacts run wait RUN_ID --timeout 3600
# discolike contacts run get RUN_ID --all-rows

# enrich_crm_export.py: one company profile by domain
discolike company data stripe.com --format json
Expand Down
66 changes: 29 additions & 37 deletions examples/contacts_at_results.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,49 @@
"""Discover companies for an ICP, then find decision makers at the top N by seniority, department, or title."""
"""Automatically find and deliver validated decision-maker contacts for a company ICP."""

from __future__ import annotations

import argparse

from discolike import Discolike
from discolike.requests import ContactsSearchParams
from discolike.requests import DiscoverParams

SENIORITIES = ["executive", "vp", "director", "manager", "senior_ic", "mid_level", "entry_level"]
from discolike.requests import ContactsRunRequest


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("prompt", help="Natural-language ICP for the company search")
parser.add_argument("--country", action="append", help="ISO-2 country code or region alias, repeatable")
parser.add_argument("--top", type=int, default=10, help="Companies to search contacts at (default 10)")
parser.add_argument("--seniority", action="append", choices=SENIORITIES, help="Persona seniority, repeatable")
parser.add_argument("--department", action="append", help="Persona department, e.g. 'Sales - Marketing'")
parser.add_argument("--title", action="append", help="Job title term, e.g. 'Head of Growth', repeatable")
parser.add_argument("--per-company", type=int, default=3, help="Contacts per company (default 3)")
parser.add_argument("--has-email", action="store_true", help="Only contacts with an email address")
parser.add_argument(
"--top", type=int, default=10, help="Target companies delivering validated contacts (default 10)"
)
parser.add_argument("--fit", default="Senior executives and decision makers", help="Describe the people to find")
parser.add_argument("--per-company", type=int, default=3, help="Validated contacts per company (default 3)")
parser.add_argument("--email-cap-usd", type=float, help="Maximum delivery spend in USD; omit for no cap")
return parser.parse_args()


def main() -> None:
args = parse_args()
client = Discolike()

companies = client.discover(
DiscoverParams(icp_prompt=args.prompt, country=args.country, max_records=max(args.top, 5))
)
domains = [company.domain for company in companies[: args.top] if company.domain]
print(f"Top {len(domains)} companies: {', '.join(domains)}")

contacts = client.contacts.search(
ContactsSearchParams(
domain=domains,
seniority=args.seniority,
department=args.department,
title=args.title,
has_email=args.has_email,
results_by_company=args.per_company,
max_records=max(len(domains) * args.per_company, 20),
)
)
print(f"\n{'domain':<28} {'name':<28} {'title':<40} email")
for contact in contacts:
print(
f"{contact.domain or '':<28} {(contact.name or '')[:28]:<28} "
f"{(contact.title or '')[:40]:<40} {contact.email or ''}"
)
print(f"\n{len(contacts)} contacts across {len({contact.domain for contact in contacts})} companies")
body = {
"companies": {
"source": "prompt",
"icp_text": args.prompt,
"filters": {"filter_country": args.country} if args.country else {},
},
"people": {"fit": args.fit},
"max_companies": args.top,
"per_company": args.per_company,
}
if args.email_cap_usd is not None:
body["email_cap_usd"] = args.email_cap_usd
with Discolike() as client:
run = client.contacts_run.create(ContactsRunRequest.model_validate(body))
print(f"Run {run.id}: {run.status}")
run = client.contacts_run.wait(run.id)
print(f"Finished: {run.status}; summary: {run.summary}")
for contact in client.contacts_run.rows(run.id):
print(
f"{contact.domain}\t{contact.name or ''}\t{contact.title or ''}\t{contact.email or ''}\t{contact.email_status or ''}"
)


if __name__ == "__main__":
Expand Down
2 changes: 1 addition & 1 deletion packages/discolike-cli/src/discolike_cli/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ def format_help(self, ctx: Context, formatter: HelpFormatter) -> None:
""",
"extract": """\
Output (success, exit 0):
{"text": <string>, "language": <ISO 639-1>}
{"text": <string>}
Fetches the page live; use it to confirm a seed domain is the right company.

Common errors:
Expand Down
Loading
Loading