Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
41ee19b
changelog: typed columns without TypeSafe now 400 at submit
yudelevi Sep 25, 2026
9d436a0
Add managed prospecting SDK and CLI workflows
yudelevi Sep 25, 2026
2daa3d1
Explain prospecting scope rejection in SDK guidance
yudelevi Sep 26, 2026
8008cfc
Clarify prospecting customer credential requirements
yudelevi Sep 26, 2026
2f4a9cc
Clarify customer-funded agent coordination
yudelevi Sep 26, 2026
498a592
Support prospecting chat approval and messages in SDK and CLI
yudelevi Sep 27, 2026
f71e90f
Surface every saved contact list on a prospecting run
yudelevi Sep 27, 2026
99000c6
Track chat_closed and the misuse stop reason on prospecting runs
yudelevi Sep 28, 2026
c80becc
Reject control-character idempotency keys and check response field ty…
yudelevi Sep 28, 2026
33150a9
Add keyset paging to prospecting.list via before
yudelevi Sep 28, 2026
37ae830
Support prospecting checkpoints in the SDK and CLI
yudelevi Sep 28, 2026
395b5e3
Resolve named enum refs in the contract check and parse the companies…
yudelevi Sep 28, 2026
6c6d593
Correct auto-mode pilot behavior: sharpen and continue, not stop
yudelevi Sep 28, 2026
9f438d9
Describe the CLI summary filters as the word match they are
yudelevi Sep 28, 2026
3785878
Keep released SDKs parsing newer API responses
yudelevi Sep 28, 2026
3db4c24
Add seeded prospecting and segment selection to the SDK and CLI
yudelevi Sep 28, 2026
39185f6
Default ProspectingBrief to 1000 companies / 1 contact each
yudelevi Sep 28, 2026
dbec50f
Document that reply_pending holds through results grouping
yudelevi Sep 29, 2026
635f2cf
Collapse the unreleased changelog to what a release adds
yudelevi Sep 29, 2026
50c9385
Cancel prospecting runs via POST /cancel, add delete
yudelevi Sep 29, 2026
3126619
Add prospecting.rename for PATCH /prospecting/runs/{run_id}
yudelevi Sep 29, 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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
python-version: ['3.11', '3.12', '3.13', '3.14']
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
Expand All @@ -36,7 +36,7 @@ jobs:
- resolution: highest
python-version: '3.14'
- resolution: lowest-direct
python-version: '3.10'
python-version: '3.11'
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ repos:
rev: v3.21.2
hooks:
- id: pyupgrade
args: [--py310-plus, --keep-mock]
args: [--py311-plus, --keep-mock]
- repo: https://github.com/bwhmather/ssort
rev: 0.16.0
hooks:
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

- **Breaking:** Python 3.10 is no longer supported (end of life October 2026); the SDK and CLI require Python 3.11+.
- **Breaking:** the polling deadline on `Job.wait()`, `EmailJob.wait()` and `EmailBatch.results()` (sync and async) is renamed from `timeout=` to `max_wait=`, and `signup()` / `async_signup()` take `request_timeout=` instead of `timeout=`. Behavior is unchanged; the client constructor, `with_options(timeout=...)` and the CLI's `--timeout` keep their names.
- SDK: response enums such as `JobStatus` are open, so a value a newer API adds parses instead of failing. Compare against the documented values and treat anything else as unknown.
- SDK/CLI: managed prospecting. `client.prospecting` starts, reads, lists, approves, messages, renames, cancels, deletes and waits on runs (sync and async), with typed plans, chat, progress and saved contact lists (`saved_query_ids`, split into parts for large results). `ProspectingBrief.checkpoints="ask"` pauses the run at a pilot, a drifting search, a shortfall or the target for you to answer; `customer_domains` groups your customers into segments and finds lookalikes of each, picked on approval with `seed_segments`. `rename()` retitles a run in any status with `ProspectingRunUpdate(title=...)` and returns its `list()` summary; result lists it already saved keep their names. `cancel()` stops a run and keeps it; `delete()` cancels an active run, then removes it from `list()` and `get()` (charges already incurred stay). The CLI adds `prospecting start/status/list/approve/message/cancel/wait`, asks at checkpoints on a terminal, and exits 7 with the question on stderr under `--no-input`.

## 0.4.1 (2026-09-23)

- CLI: fix `ImportError: cannot import name 'Abort' from 'typer._click.exceptions'` on every command in a fresh 0.4.0 install. Typer 0.27 moved `Abort`; the CLI now imports the public `typer.Abort` and requires `typer>=0.26.1,<0.28`, since it still relies on Typer's vendored Click for its error envelope and help formatting.
Expand Down
26 changes: 25 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ uv run pytest packages/discolike/tests -q
uv run pytest packages/discolike-cli/tests -q
```

CI runs the same checks across Python 3.10–3.14.
CI runs the same checks across Python 3.11–3.14.

## Branches

Expand All @@ -42,6 +42,30 @@ models in `discolike.requests` track dev, not prod. The prod spec lags, so
both `check_contract.py` and `gen_requests.py --check` against prod stay red
until the platform deploys; don't regenerate against prod to "fix" it.

## Compatibility

Released SDK versions stay installed long after a new one ships, and they
all talk to the same live API. Keep them working:

- **Response models parse anything a newer server can send.** Models allow
extra fields (`extra="allow"`). Response enums are open
(`Literal[...] | str`), never a closed `Literal`. Response fields carry no
request-side limits (`max_length`, `ge`/`le`). Don't reuse a request model
as a response field (see `ProspectingRunBrief`). A new response field gets
a default, so the SDK still parses servers from before it existed.
- **The SDK may be looser than the spec, never stricter.** The contract check
flags only drift that breaks parsing: the SDK requiring a field the spec
makes optional, or the spec allowing a type the SDK rejects.
- **Public signatures don't change silently.** Never rename or remove a
public method, kwarg, exception, or exported name in a patch release. When
one has to go, keep the old spelling working with a `DeprecationWarning`
that names the replacement for at least one minor release, and record the
removal under **Breaking** in `CHANGELOG.md`.
- **New request fields are optional** and omitted from the wire when unset, so
a newer SDK still works against an API that hasn't deployed them yet.
- **Python support follows upstream EOL.** Drop a version once it reaches
end of life, as a **Breaking** CHANGELOG line.

## Reporting bugs

Open a GitHub issue with the package name, version, and a minimal
Expand Down
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Or run the CLI without installing:
uvx --from discolike-cli discolike --help
```

Requires Python 3.10+.
Requires Python 3.11+.

## Authentication

Expand Down Expand Up @@ -274,6 +274,7 @@ Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `ap
| 4 | Rate limited |
| 5 | Network error |
| 6 | Not found |
| 7 | Needs input: `prospecting wait` reached a checkpoint with no terminal to ask (or `--no-input`) |

## What's in the box

Expand Down Expand Up @@ -421,3 +422,48 @@ Committed request models track the dev spec (`--spec-url https://api.dev.discoli
## License

[MIT](LICENSE)


### Managed prospecting

REST starts in `drafting`, then waits at `proposed` for approval. Review the plan and approve its exact version before research starts.

```python
from discolike.requests import (
ProspectingApproveRequest, ProspectingBrief, ProspectingGetParams,
ProspectingListParams, ProspectingMessageRequest, ProspectingRunUpdate,
)

run = client.prospecting.start(
ProspectingBrief(brief="Find 100 US logistics companies and 3 operations directors each"),
idempotency_key="logistics-search-2026-09-26",
)
run = client.prospecting.wait(run.run_id)
print(run.status, run.plan, run.messages) # Review before approving.
# After reviewing a proposed plan:
# client.prospecting.approve(run.run_id, ProspectingApproveRequest(plan_version=run.plan_version))
# run = client.prospecting.wait(run.run_id)

recent = client.prospecting.list(ProspectingListParams(limit=20))
message = client.prospecting.message(
run.run_id, ProspectingMessageRequest(text="Make it 250 companies"),
idempotency_key="logistics-target-edit-1",
)
page = client.prospecting.get(
run.run_id,
ProspectingGetParams(offset=0, limit=100, events_after=run.next_event_seq, messages_after=run.next_message_seq),
)
# client.prospecting.rename(run.run_id, ProspectingRunUpdate(title="Logistics ops leaders")) # Any status.
# client.prospecting.cancel(run.run_id) # Stop the run; it and its results stay readable.
# client.prospecting.delete(run.run_id) # Cancel if active, then remove it from list() and get().
```

The async client exposes the same methods with `await`. Starts and messages require separate idempotency keys; reuse each key when retrying that operation. Approving an already approved version is safe. A stale plan version is rejected: fetch the current plan and review it again.

`wait()` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`. Inspect `status`, `stop_reason`, and `error`; completion does not guarantee the target was reached. A local timeout stops polling only. Partial results remain available. Use `get()` with event and message cursors to receive the agent's reply after sending a message; `reply_pending` indicates a pending reply. After a "segment these" request on a finished run it stays true past the acknowledgement until the segments message is posted, which can take more than an hour, and clears on its own after about 90 minutes if grouping stops without an outcome. A `needs_input` question can be answered with `message()`.

Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and checks again, then stops with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief. A search drifting off target is dropped, a run short of candidates finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`). The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway.

Initial planning extracts company counts and contacts per company from the brief. Omitted settings keep that inference available, falling back to 1,000 companies and 1 contact per company. Explicit settings, including explicit defaults, override the text. Targets support 1–10,000 companies and 1–5 contacts per company. Candidate and action caps default to automatic (`0`); explicit maxima are 100,000 candidates and 10,000 actions. Result pages support up to 500 rows; recent-run lists support up to 50. Approved runs expose a stable `saved_query_id` for saved results.

Existing processing charges and configured BYOK/BYOS integrations apply. Wizard interpretation, segmentation, and prompt preparation use platform credentials. Agent coordination and independent contact qualification use your contact LLM integration; native contacts use your validation LLM integration or organization default, so this workflow requires a customer LLM even with native extraction. Missing keys and provider errors never fall back to platform keys. Limits bound work, not provider dollar spend. Email finder outcomes are exposed; raw email verification is not a public API.
25 changes: 23 additions & 2 deletions packages/discolike-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Or run it without installing:
uvx --from discolike-cli discolike --help
```

Requires Python 3.10+. Installing this package gives you the `discolike` command.
Requires Python 3.11+. Installing this package gives you the `discolike` command.

## Authentication

Expand All @@ -35,7 +35,7 @@ discolike company data stripe.com
discolike extract https://stripe.com/enterprise
```

Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment` — plus `auth`, `bulk`, `company`, `contacts`, `discogen`, `queries`, `account`, `search-providers`, and `llm-providers` command groups.
Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment` — plus `auth`, `bulk`, `company`, `contacts`, `discogen`, `prospecting`, `queries`, `account`, `search-providers`, and `llm-providers` command groups.

### Volume pulls

Expand All @@ -49,6 +49,26 @@ discolike bulk contacts --domains-file companies.csv --per-company 10 --summary

`companies` saves each page as an exclusion list (`<run-name>-round-N`) and excludes it from the next page; rerunning with the same `--out` resumes from the CSV. `contacts` slices the domain list at `10000 / per-company` domains per call and records finished slices in `<out>.checkpoint`. Both keep one call in flight under `--rate-limit` (default 10/min, the Pro rate on `/discover` and `/contacts`), retry on 429/5xx, and print a JSON summary at the end. Filters come from `--params-file`, `--param` and the common flags; the paging fields are managed for you.

### Managed prospecting

```bash
discolike prospecting start --brief "Find 100 US logistics companies and 3 operations directors each" --idempotency-key logistics-1
discolike prospecting start --brief "Find lookalikes of our customers and their CTOs" --customers-file customers.csv --idempotency-key seeded-1
discolike prospecting wait RUN_ID
# Review the proposed plan, then approve the exact version you saw:
discolike prospecting approve RUN_ID --plan-version 1
discolike prospecting list --limit 20
discolike prospecting message RUN_ID --text "Make it 250 companies" --idempotency-key logistics-edit-1
discolike prospecting status RUN_ID --events-after 12 --messages-after 8 --limit 100
discolike prospecting cancel RUN_ID
```

`wait` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`. A timeout stops polling only. Inspect status and stop reason; completion does not guarantee full coverage. Message replies arrive through `status --messages-after`; follow `next_message_seq` and `reply_pending`. A "segment these" request keeps `reply_pending` true until the segments message is posted, which can take more than an hour. If grouping stops without an outcome, the flag clears on its own after about 90 minutes.

`start` and `approve` default to pausing at checkpoints, like the web chat: a pilot check on large lists (`pilot`), a search drifting off target (`tail_quality`), candidates running out short of the target (`short`), and the target being met (`target_reached`). Pass `--auto` to never pause; a poor pilot is then sharpened once and the run continues with a notice, stopping with `pilot_failed` only if the re-pilot fit is still under 20%. If sharpening itself fails, the run continues on the original criteria. On a terminal, `wait` shows the question, any sample companies and numbered replies at a checkpoint, sends your pick or your own text, and keeps waiting. Without a terminal, or with `--no-input`, it prints the run on stdout, a `needs_input` envelope (`message`, `stop_reason`, `suggested_replies`, `sample`) on stderr, and exits 7; answer with `prospecting message --text "<reply>"` and run `wait` again.

Omit `--target-companies` and `--contacts-per-company` to infer counts from the brief (fallback 25 and 2). Explicit values override the text. `--max-candidates` and `--max-actions` are automatic when omitted or `0`; their maxima are 100,000 and 10,000. Targets allow up to 10,000 companies, status pages up to 500 rows, and lists up to 50 runs. Work caps do not cap provider charges.

### Conventions

- Results print as JSON to stdout; errors print as JSON (`error`, `message`, `status_code`) to stderr.
Expand All @@ -67,6 +87,7 @@ discolike bulk contacts --domains-file companies.csv --per-company 10 --summary
| 4 | Rate limited |
| 5 | Network error |
| 6 | Not found |
| 7 | Needs input: `prospecting wait` reached a checkpoint with no terminal to ask (or `--no-input`) |

## Links

Expand Down
3 changes: 1 addition & 2 deletions packages/discolike-cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ description = "Official CLI for the DiscoLike API"
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.10"
requires-python = ">=3.11"
authors = [{ name = "DiscoLike", email = "support@discolike.com" }]
dependencies = [
"discolike==0.4.1",
Expand All @@ -21,7 +21,6 @@ classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
Expand Down
3 changes: 3 additions & 0 deletions packages/discolike-cli/src/discolike_cli/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ def format_help(self, ctx: Context, formatter: HelpFormatter) -> None:
4 rate_limited HTTP 429; wait "retry_after" seconds, then retry
5 network_error could not reach the API
6 not_found HTTP 404
7 needs_input `prospecting wait` stopped at a checkpoint with no terminal to ask
(or --no-input): the run is on stdout, the question and
"suggested_replies" in the stderr envelope

Environment:
{ENV_API_KEY} API key; overrides the config file written by `discolike auth login`.
Expand Down
5 changes: 3 additions & 2 deletions packages/discolike-cli/src/discolike_cli/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
NotFoundError: 6,
}
DEFAULT_EXIT_CODE = 1
NEEDS_INPUT_EXIT_CODE = 7

# Stable, snake_case error codes for agents and scripts to branch on. The class
# name in `error` is kept for backwards compatibility; `code` is the contract.
Expand Down Expand Up @@ -82,7 +83,7 @@ class SupportsWait(Protocol):
task_id: str
task_family: str

def wait(self, *, timeout: float, on_poll: Callable[[_JobStatusLike], None] | None = None) -> _JobStatusLike: ...
def wait(self, *, max_wait: float, on_poll: Callable[[_JobStatusLike], None] | None = None) -> _JobStatusLike: ...


def _normalize(data: Any) -> Any: # noqa: ANN401 -- accepts arbitrary JSON-serializable CLI output data
Expand Down Expand Up @@ -203,5 +204,5 @@ def run_job(job: SupportsWait, *, wait: bool, timeout: float, fmt: str | None =
def _on_poll(status: _JobStatusLike) -> None:
sys.stderr.write(f"progress: {status.progress}%\n")

final = job.wait(timeout=timeout, on_poll=_on_poll)
final = job.wait(max_wait=timeout, on_poll=_on_poll)
emit(final.results if final.results is not None else final.to_dict(), fmt=fmt)
4 changes: 2 additions & 2 deletions packages/discolike-cli/src/discolike_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
import secrets
import sys
import webbrowser
from datetime import UTC
from datetime import datetime
from datetime import timezone
from typing import Any
from typing import NoReturn
from urllib.parse import urlparse
Expand Down Expand Up @@ -88,7 +88,7 @@ def _offer_signup(ctx: typer.Context) -> None:


def _iso(epoch_seconds: float) -> str:
return datetime.fromtimestamp(epoch_seconds, tz=timezone.utc).isoformat()
return datetime.fromtimestamp(epoch_seconds, tz=UTC).isoformat()


def _global_key_source(ctx: typer.Context) -> str:
Expand Down
4 changes: 2 additions & 2 deletions packages/discolike-cli/src/discolike_cli/bulk.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,8 @@ def _persona_ids_in(path: pathlib.Path) -> set[str]:


ICP_PROMPT_HELP = "Natural-language ICP prompt used to derive contact filters."
SUMMARY_HELP = "Filter by profile summary text (semantic search); ranks who comes back per company."
NEGATE_SUMMARY_HELP = "Exclude contacts matching this summary description."
SUMMARY_HELP = "Match profile summary text: every word of any one term, in any order. Quote a multi-word term to keep it together, prefix + to require it. Ranks who comes back per company."
NEGATE_SUMMARY_HELP = "Exclude contacts whose profile summary contains every word of any one term, in any order."
HAS_EMAIL_HELP = "Only contacts with an email address (on by default)."


Expand Down
4 changes: 2 additions & 2 deletions packages/discolike-cli/src/discolike_cli/contacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
TIMEOUT_HELP = "Max seconds to wait with --wait."
PARAM_HELP = "Extra key=value query parameter forwarded to the SDK (repeatable)."
JOBSTART_DATE_HELP = "Job start date filter: min date or 'min,max' range, e.g. 2025-01-01 or 2025-01-01,2025-06-30."
SUMMARY_HELP = "Filter by profile summary text (semantic search)."
NEGATE_SUMMARY_HELP = "Exclude contacts matching this summary description."
SUMMARY_HELP = "Match profile summary text: every word of any one term, in any order. Quote a multi-word term to keep it together, prefix + to require it."
NEGATE_SUMMARY_HELP = "Exclude contacts whose profile summary contains every word of any one term, in any order."
NAME_HELP = "Filter by contact name (partial match supported)."
SKILLS_HELP = "Filter by skill (repeatable)."
FILTER_STATE_HELP = "Filter by company state/region (repeatable)."
Expand Down
2 changes: 1 addition & 1 deletion packages/discolike-cli/src/discolike_cli/discogen.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
app = typer.Typer(help="Run DiscoGen research jobs and check status of or cancel any async task (see --family)")


class TaskFamily(str, enum.Enum):
class TaskFamily(enum.StrEnum):
discogen = "discogen"
bulkmatch = "bulkmatch"
contactmatch = "contactmatch"
Expand Down
Loading
Loading