Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
15b46e0
regen: pick up the state filter descriptions from the platform spec
yudelevi Sep 3, 2026
2f1200b
fix(sdk): resend the OAuth resource on token refresh
Sep 5, 2026
60da6e0
Merge pull request #19 from discolike/fix/oauth-refresh-resource
quantumdark Sep 11, 2026
46bd3c8
Expose the sub-industry and radius filters so SDK callers are not stu…
yudelevi Sep 15, 2026
fa55f85
Exempt mirrorball's saved-query fields from the CLI parity check
yudelevi Sep 15, 2026
f3cc906
Push populated payloads and CLI flags through the new geo/sub-industr…
yudelevi Sep 15, 2026
9f19730
Merge remote-tracking branch 'origin/development' into feat/sub-indus…
yudelevi Sep 15, 2026
0f4b8ec
Read the coordinates under the names the response sends
yudelevi Sep 15, 2026
9c23107
Declare the filters the SDK ships ahead of the spec
yudelevi Sep 15, 2026
f6184ea
Parse coordinates sent under either name
yudelevi Sep 15, 2026
58cc884
Take the bbox filter the dev spec already exposes
yudelevi Sep 16, 2026
1f5f958
Reject the boxes the platform rejects, not a looser set
yudelevi Sep 16, 2026
9e5e23e
Merge pull request #20 from discolike/feat/sub-industry-geo-filters
quantumdark Sep 16, 2026
3f627f1
feat(cli): agent output contract in --help and stable error codes
Sep 15, 2026
4dbee8a
fix(cli): document validate-icp, segment, and contacts generate resul…
Sep 15, 2026
efccd2c
fix(cli): route parser errors through the JSON envelope, login JSON o…
Sep 15, 2026
1e0790a
Merge pull request #21 from discolike/feat/cli-agent-contract
quantumdark Sep 16, 2026
351273e
feat(cli): --domains-file, --params-file, --exclude-domains-file on v…
Sep 15, 2026
aa7ac81
test(cli): plain_output testkit helper; rich wraps usage panels on Gi…
Sep 16, 2026
cd58ba2
Merge pull request #26 from discolike/feat/domains-file-params-file
quantumdark Sep 16, 2026
3b90fce
feat(cli): discolike bulk companies|estimate|contacts
Sep 15, 2026
8bebb1d
examples: discolike bulk volume recipe in cli_recipes.sh
Sep 16, 2026
4d19b39
fix(cli): bulk review follow-ups — checkpoint identity, inline tails,…
Sep 16, 2026
454b0bd
Merge pull request #27 from discolike/feat/bulk
quantumdark Sep 16, 2026
74c2d71
Take the several places a search can now cover
yudelevi Sep 16, 2026
9012285
Take foxguard 0.14.0 in the hooks
yudelevi Sep 16, 2026
887ec42
Support the native ContaGen engine in contacts.generate
yudelevi Sep 17, 2026
ce6b2fd
Name the native extractor DiscoLike Groove
yudelevi Sep 17, 2026
5dc63ab
test: cover the native ContaGen engine contract
yudelevi Sep 18, 2026
f9cd374
Merge pull request #28 from discolike/contagen-native-engine
yudelevi Sep 18, 2026
9d759c6
Let validate_icp run on the native ICP-fit model
yudelevi Sep 18, 2026
0dabde9
Say that a native ICP run never returns reasoning
yudelevi Sep 18, 2026
3cb3fe6
Document one casing for the ICP-fit verdict
yudelevi Sep 18, 2026
5a40231
Carry include_confidence through the SDK and CLI
yudelevi Sep 21, 2026
2e0d52f
Merge pull request #29 from discolike/native-icp-validation
yudelevi Sep 22, 2026
99c2863
Carry the typed_columns opt-in flag through the SDK and CLI
yudelevi Sep 22, 2026
b7c2fa5
Cover --include-confidence on/off/unset in the CLI tests
yudelevi Sep 22, 2026
9a15d49
Check any non-release PR against the dev spec
yudelevi Sep 22, 2026
8b466d4
Merge remote-tracking branch 'origin/development' into feat/discogen-…
yudelevi Sep 22, 2026
b462bb0
Merge branch 'feat/discogen-typed-columns' into feat/typed-columns-flag
yudelevi Sep 22, 2026
c560879
Merge pull request #31 from discolike/feat/typed-columns-flag
yudelevi Sep 22, 2026
f2f28c4
Trigger checks for the merged typed-columns stack
yudelevi Sep 22, 2026
3bd6db2
Merge pull request #30 from discolike/feat/discogen-typed-columns
yudelevi Sep 22, 2026
4c13898
feat: find_emails on ContactGenerateRequest and --find-emails on cont…
Sep 15, 2026
c2fd539
Merge pull request #22 from discolike/feat/contacts-generate-find-emails
quantumdark Sep 23, 2026
cc98014
release: 0.4.0
Sep 23, 2026
f6789e4
Address PR #32 review findings before the 0.4.0 release
yudelevi Sep 24, 2026
3759846
Merge pull request #33 from discolike/fix/pr32-review
yudelevi Sep 24, 2026
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
6 changes: 4 additions & 2 deletions .github/workflows/contract.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@ jobs:
python-version: '3.14'
- run: uv sync --all-packages
# SDK features land on development before the platform deploys to
# prod, so PRs targeting development check against the dev spec.
# prod, so every PR but a release into main checks against the dev
# spec — including one stacked on another feature branch, which is
# just as far ahead of prod as the branch it targets.
# DEV_SPEC_URL is a repo secret; fork PRs don't receive it and
# fall back to the prod spec, keeping dev infra internal-only.
- env:
BASE_REF: ${{ github.base_ref }}
DEV_SPEC_URL: ${{ secrets.DEV_SPEC_URL }}
run: |
if [ "$BASE_REF" = "development" ] && [ -n "$DEV_SPEC_URL" ]; then
if [ "$BASE_REF" != "main" ] && [ -n "$DEV_SPEC_URL" ]; then
uv run python scripts/check_contract.py --spec-url "$DEV_SPEC_URL"
uv run python scripts/gen_requests.py --check --spec-url "$DEV_SPEC_URL"
else
Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,13 @@ repos:
hooks:
- id: foxguard
name: foxguard
entry: npx -y foxguard@0.10.0 --staged
entry: npx -y foxguard@0.14.0 --staged
language: system
pass_filenames: false
always_run: true
- id: foxguard-secrets
name: foxguard-secrets
entry: npx -y foxguard@0.10.0 secrets --staged
entry: npx -y foxguard@0.14.0 secrets --staged
language: system
pass_filenames: false
always_run: true
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
# Changelog

## 0.4.0 (2026-09-23)

- SDK: `validate_icp` can run without an LLM key. Pass `integration_id=NATIVE_ICP_ENGINE` (exported from `discolike`, the string `"native-icp"`) to score with DiscoLike's own ICP-fit model instead of your BYOK LLM: no LLM cost, no LLM key, and no web search on that run. The same sentinel works on `discogen.process` for a prompt that already carries the validation structure. Two new errors are specific to it: a 400 `ValidationError` when the ICP text does not yield a Mandatory / Reject if / Nice-to-have prompt, and a 503 `ServerError` when no ICP-fit engine is available. Task lifecycle, polling and statuses are unchanged.
- SDK: `Job` / `AsyncJob` returned by `validate_icp`, `discogen.process` and `discogen.process_personas` carry `column_name` from the submit response. The engine picks the columns — an LLM validation returns `Fit` / `Confidence` / `Reasoning`, the native model `ICP Fit` (`Yes` / `No` at a 0.50 threshold on the score) / `ICP Score` (the calibrated probability, 0.00-1.00 as a string) / `Reasoning (always null)` — so read it rather than hardcoding either set. It is `None` on a job reattached with `discogen.job(task_id)`.
- CLI: `validate-icp --integration-id native-icp` and `discogen run --integration-id native-icp` select the native ICP-fit model; `discolike validate-icp --help` documents both column sets.
- **Breaking:** the ICP-fit verdict is title case on every surface. An LLM `validate_icp` run's `Fit` column now returns `Yes` / `No` instead of `yes` / `no`, matching the native engine's `ICP Fit` column, DiscoGen and the app. Code that matches the verdict on the exact string `"yes"` has to be updated; compare case-insensitively to survive either.
- SDK/CLI: `discogen.process` and `discogen.process_personas` take `typed_columns`, and the CLI gains `--typed-columns`. It opts the query into the detector answering yes/no, fixed-set and scale columns with a TypeSafe judgment model instead of generated prose; which columns are typed and how is decided server-side from the query text, not passed in by the caller. Off by default. See `include_confidence` below for the sibling flag that adds a confidence column to each typed column.
- SDK/CLI: `discogen.process` and `discogen.process_personas` take `include_confidence`, and the CLI gains `--include-confidence`. It applies to typed columns, which a DiscoGen run answers with a TypeSafe judgment model rather than generated prose; with it on, each typed column gets a sibling confidence column. Off by default, and it changes display only - the answers and their probabilities come back either way.
- SDK: `contacts.generate` can run without an LLM key. Pass `integration_id=NATIVE_ENGINE` (exported from `discolike`) for DiscoLike Groove, DiscoLike's native extractor, or omit `integration_id` to get your default LLM integration and native when you have none. The native engine returns every person the search surfaces with a title and does not validate titles against `icp_text`; a search provider is still required. `JobStatus` gains `title_validation` (`"llm"` or `"none"`, `None` on non-ContaGen jobs) so you can tell which engine ran.
- SDK: discover and count take `sub_industry` / `negate_sub_industry` (217 second-level labels scoped to their parent industry category; a bare label like `ROOFING` adds `CONSTRUCTION` to `category` server-side) and a multi-shape geo filter: `geo` (`lat,lon` or `lat,lon,radius`), `bbox` (`min_lat,min_lon,max_lat,max_lon`, longitudes may wrap the antimeridian) and `lat` / `lon` / `radius` (`50km`, `30mi`, or a bare number meaning kilometres; defaults to 50km). `geo` and `bbox` are lists, every circle and box is OR'd with the others and with the `lat`/`lon` centre, and a query carries at most 10 shapes. Passing a single `bbox` or `geo` string still works and is sent as a one-element list. The SDK applies the platform's own shape rules before the request leaves the client: a box is four finite numbers with `-90 <= min_lat < max_lat <= 90`, longitudes within ±180 and `min_lon` different from `max_lon`; a circle is a valid coordinate pair with an optional radius above 0 and at most 1000km. The cross-field rules are checked locally too: `lat` and `lon` come together, `radius` needs them and follows the same radius rule, and the centre, `geo` and `bbox` total at most 10 shapes.
- SDK: `CompanyProfile` gains `sub_industry` (label:confidence, `None` when the domain was never scored against its category's sub-labels), `latitude`, `longitude` and `geo_precision`. The coordinates come back under their full names, and a response still using the pre-rename `lat` / `lon` keys parses into them, so the SDK works against an API on either side of that release. The `lat` / `lon` discover and count filters keep their names.
- CLI: `discover` and `count` gain `--sub-industry`, `--negate-sub-industry`, `--lat`, `--lon`, `--radius`, `--geo` and `--bbox`. `--geo` and `--bbox` are repeatable, one flag per shape.
- CLI: error envelopes on stderr gain a stable snake_case `code` (`validation_error`, `auth_required`, `auth_invalid`, `plan_access`, `rate_limited`, `network_error`, `not_found`, `server_error`, `job_failed`, `job_timeout`) and an `exit_code`, next to the existing `error` class name, `message`, and `status_code`. Agents and scripts should branch on `code` or the exit code.
- CLI: `discolike --help` ends with the output contract (JSON on stdout, envelope on stderr, when JSON is the default), the exit-code table, environment variables, and jq examples. `discover`, `count`, `match`, `append`, `validate-icp`, `segment`, `extract`, `signup`, `contacts search`, `contacts generate`, `discogen run`, `discogen status`, `queries create-exclusion-list`, `auth login`, `auth status`, and `account usage` each document their success JSON shape and common error codes under "Output (success, exit 0)" and "Common errors". Epilogs print verbatim instead of being reflowed.
- CLI: the console entry point is now `discolike_cli.main:run`. Parser failures (unknown flag, bad typed value, missing argument, `typer.BadParameter`) print the same `{"code": "validation_error", ...}` envelope on stderr with exit 2 instead of click's usage text; `--help`, `--version`, and the bare `discolike` help screen are unchanged; Ctrl-C exits 130.
- CLI: `discolike auth login` writes its success JSON (`{"logged_in": true, ...}`) to stdout like every other command; the URL to open and other progress lines stay on stderr.
- CLI: `auth_required` is reported only when no credential was found at all; a stored OAuth credential that fails to refresh (expired session, malformed token response) reports `auth_invalid`.
- CLI: `discolike --version` prints the bare CLI version when stdout is not a TTY; the decorated line with the SDK version is unchanged on a terminal.
- CLI: domain lists from files — `--domains-file PATH` (CSV with a `domain` column, or one domain per line; merged with `--domain`) on `queries create-exclusion-list`, `contacts discover`, `contacts search`, `contacts count`, `contacts generate` and `discogen run`; `validate-icp --file` gains the `--domains-file` alias and reads the same shapes. `--domain` on `contacts generate` / `discogen run` is now optional when the file is given.
- CLI: `--params-file PATH` on `discover`, `count`, `contacts discover|search|count` — a JSON object of API parameter names (an app form copied over), validated locally with the SDK request model before the call. Precedence: file < `--param` < flags.
- CLI: `discover --exclude-domains-file PATH` merges a file into the inline `exclude_domain` list (100 max, checked locally).
- CLI: `discolike bulk companies|estimate|contacts` — the volume pipeline as plain CLI calls. `companies` loops `discover` at up to 10,000 per page, saves each page as an exclusion list (`<run-name>-round-N`) for the next page, appends to `--out` as pages land and resumes from that CSV on rerun, re-excluding every domain already in it even when `--exclusion-query-id` is given (warns at the 250,000-domain exclusion capacity). `estimate` sums the free `contacts count` over 1,000-domain slices and reports the upper bound at `--per-company`. `contacts` slices the domain list at `10000 / per-company` per `contacts discover` call (`results_by_company`, no `offset`), flattens to one row per contact, checkpoints finished slices in `<out>.checkpoint` (stamped with the domains, `--per-company` and filters it was written for, so a rerun with different inputs is refused instead of silently skipped) and skips them on rerun. All three validate the request locally before the first billable call, keep one call in flight under `--rate-limit`, retry on 429/5xx, and print a JSON summary on stdout.
- SDK: `ContactGenerateRequest.find_emails` (default `False`) asks the platform to run the email finder over every named, email-less row before the ContaGen job completes, filling `email` and `email_status` on those rows. Found addresses bill under the finder's rules; the rest of the job stays unbilled.
- CLI: `discolike contacts generate --find-emails` sets the same flag, so a generate run can return name + title + email triples without chaining `email find-batch` afterwards.
- SDK: OAuth refreshes now resend the RFC 8707 `resource` the token was issued for. `OAuthCredential` gains an optional `resource` field, filled in by `exchange_code` and persisted to the config file; credentials stored by earlier releases load with `resource=None` and refresh as before until the next `discolike auth login`. Without it, an authorization server configured with a default resource could re-bind a refreshed REST token to another audience.
- SDK (behavior change, no code change): company `address.state` now comes back from the API as the subdivision name ("California", "Tokyo") instead of the ISO code ("CA", "13"). `CompanyAddress.state` is still `str | None` and needs no migration, but anything joining or grouping on that value as a code has to resolve it. The contact's own `state` is unchanged and stays a code.
- SDK: state filters accept a code or a name, resolved server-side against the countries you selected. Discover/count still take one `country` value, but that value may be a region alias (`EU`, `APAC`, `DACH`) and the state resolves against every member. Contacts state filters accept multiple countries and drop a value they cannot resolve rather than erroring.
- SDK: `MatchCompanyParams.state` works for any country with subdivisions, not just the US, and takes a code or a name.
- SDK: regenerated request models — the state field descriptions above now ship in `discolike.requests`.
- SDK (note for maintainers): `geo` and the widened `bbox` were written into `discolike/_generated/requests.py` by hand, ahead of the platform deploy that puts them in the OpenAPI spec. `scripts/gen_requests.py` rebuilds that file wholesale from the spec, so until the deploy lands `--check` reports those fields as a diff; that is the pending release, not drift. Regenerate once api-dev serves them.

## 0.3.2 (2026-09-02)

- CLI: every SDK request field now has a flag — `discover`/`count` gain `--variance`, `--min-similarity`, `--consensus`, `--inclusion-query-id`, `--language`, `--social`, `--subdomain`, `--start-date`, `--redirect`, `--exclude-leadgen` and the `--auto-*` toggles; contacts `search`/`count`/`discover` gain the full filter set; `match` gains per-column flags for file mode and `--min-match-confidence`; `append`/`segment` take `--query-id`; `extract` accepts `--domain`. Dict-typed fields stay `--param` only.
Expand Down
83 changes: 83 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,45 @@ for company in companies:
print(company.domain, company.name, company.similarity)
```

Narrow to a sub-industry within a radius of a point:

```python
from discolike import Discolike
from discolike.requests import DiscoverParams

client = Discolike()

roofers = client.discover(
DiscoverParams(
sub_industry=["ROOFING"],
lat=30.2672,
lon=-97.7431,
radius="50mi",
max_records=25,
)
)
for company in roofers:
print(company.domain, company.name, company.sub_industry)
```

A bare `ROOFING` resolves to `CONSTRUCTION/ROOFING` and adds `CONSTRUCTION` to the category filter
server-side; pass the qualified form yourself if you would rather be explicit.

One query can cover several areas at once. `geo` takes `lat,lon` or `lat,lon,radius` and `bbox` takes
`min_lat,min_lon,max_lat,max_lon`; both are lists, and every circle, every box and the `lat`/`lon`
centre are OR'd together, up to 10 shapes:

```python
austin_dallas_and_houston = client.discover(
DiscoverParams(
sub_industry=["ROOFING"],
geo=["30.2672,-97.7431,30mi", "32.7767,-96.797,30mi"],
bbox=["29.6,-95.7,30.1,-95.0"],
max_records=25,
)
)
```

Run DiscoGen research over a set of domains and wait for the result:

```python
Expand Down Expand Up @@ -270,6 +309,50 @@ result = job.wait()

`JobTimeoutError` is a client-side wait limit only — the task keeps running server-side (large DiscoGen runs can take hours), so call `wait()` again to resume or fetch `status()` later. Cancelled tasks still return results for every item that finished before cancellation. Send one job per list (up to 10,000 domains) rather than splitting into parallel jobs — concurrent DiscoGen jobs share your LLM provider key and slow each other down.

### Contact generation without an LLM key

`contacts.generate` runs on your own search provider plus either your own LLM or DiscoLike Groove, DiscoLike's native extractor. Pass `NATIVE_ENGINE` to skip the LLM entirely:

```python
from discolike import NATIVE_ENGINE
from discolike.requests import ContactGenerateRequest

job = client.contacts.generate(
ContactGenerateRequest(
icp_text="VPs or Directors of Marketing at B2B SaaS",
domains=["gusto.com", "rippling.com"],
integration_id=NATIVE_ENGINE,
)
)
result = job.wait()
print(result.title_validation) # "none" on the native engine, "llm" on a BYOK run
```

The native engine returns every person the search surfaces with a title and does not validate titles against `icp_text`, so filter them yourself when that matters. Omit `integration_id` to use your default LLM integration, or native when you have none. A search provider is required either way.

### ICP validation without an LLM key

`validate_icp` runs your ICP text against each domain on your own LLM provider key, or on DiscoLike's own ICP-fit model. Pass `NATIVE_ICP_ENGINE` for the latter — no LLM key, no LLM cost, and no web search on that run:

```python
from discolike import NATIVE_ICP_ENGINE
from discolike.requests import ValidateIcpRequest

job = client.validate_icp(
ValidateIcpRequest(
icp_text="Cybersecurity for SMBs in North America, 50-500 employees",
domains=["gusto.com", "rippling.com"],
integration_id=NATIVE_ICP_ENGINE,
)
)
print(job.column_name) # ["ICP Fit", "ICP Score", "Reasoning"]
result = job.wait()
```

The engine decides the result columns, so read `job.column_name` instead of hardcoding them: an LLM run returns `Fit` / `Confidence` / `Reasoning`, the native model `ICP Fit` / `ICP Score` / `Reasoning (always null)`. `ICP Fit` is `Yes` or `No` at a 0.50 threshold on `ICP Score`, the calibrated probability as a 0.00-1.00 string. The native model returns only that score, so `Reasoning` is always `null` — the column is there to keep the set the same shape as an LLM run, not to carry an explanation. `integration_id="native-icp"` also works on `discogen.process` for a prompt that already carries the validation structure.

Two errors are specific to the native engine: a 400 `ValidationError` when the ICP text does not yield a Mandatory / Reject if / Nice-to-have prompt, and a 503 `ServerError` when no ICP-fit engine is available. Task lifecycle, polling and statuses are the same either way.

### Error handling

All errors inherit from `DiscolikeError`:
Expand Down
2 changes: 1 addition & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ export DISCOLIKE_API_KEY="dl_..." # create one at https://app.discolike.com/ac
| [`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 | `bash examples/cli_recipes.sh` |
| [`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` |

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`.
7 changes: 7 additions & 0 deletions examples/cli_recipes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,10 @@ discolike company data stripe.com --format json

# agent_signup_to_first_search.py: open an account for a person, no auth needed
discolike signup --email jane@acme.com --first-name Jane --last-name Doe --agent cookbook

# Volume: every company that matches, then N contacts at each, checkpointed and resumable.
# form.json is a JSON object of API parameter names (an app Discover form copied over); count is free.
discolike count --params-file form.json --format json
discolike bulk companies --params-file form.json --max-companies 50000 --run-name agencies --out companies.csv
discolike bulk estimate --domains-file companies.csv --per-company 10 --summary "growth marketing lead" # free
discolike bulk contacts --domains-file companies.csv --per-company 10 --summary "growth marketing lead" --out contacts.csv
Loading
Loading