Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
- **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`.
- SDK: `client.prospecting.update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` (sync and async) changes a proposed plan's contact engine, company-check engine, search provider (`"none"` skips web research) and `max_spend_usd` (a USD spending limit on records and per-request fees at the plan's rates; `0` removes it) before approval, then returns the run with the re-estimated plan at a new `plan_version`. Prospecting `stop_reason` gains `candidate_limit` (candidate cap reached) and `credit_limit` (stopped at the spending limit, results kept); `candidates_exhausted` now means only that the search ran dry. `stop_reason` stays an open string. Runs without a chosen contact engine research on DiscoLike Groove, or find indexed contacts only with no search provider.
- SDK: `client.prospecting.answer_intake(run_id, answers, idempotency_key=..., summary=None)` (sync and async) answers the guided-intake card a run asks before it drafts a plan. `answers` maps a question key (`company_activity`, `industry`, `geography`, `company_size`, `persona_roles`, `list_size`) to `IntakeAnswer(values=[...], other="...")`; the card's fields are on the `kind="question"` message's `data`. `ProspectingMessageRequest.text` is now optional, so a message can carry `intake` alone.
- SDK: `ProspectingInFlight` gains `progress` (0-100 or `None`) for the running item.

## 0.4.1 (2026-09-23)

Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ REST starts in `drafting`, then waits at `proposed` for approval. Review the pla
```python
from discolike.requests import (
ProspectingApproveRequest, ProspectingBrief, ProspectingGetParams,
ProspectingListParams, ProspectingMessageRequest, ProspectingRunUpdate,
ProspectingListParams, ProspectingMessageRequest, ProspectingPlanSettings, ProspectingRunUpdate,
)

run = client.prospecting.start(
Expand All @@ -453,6 +453,8 @@ page = client.prospecting.get(
run.run_id,
ProspectingGetParams(offset=0, limit=100, events_after=run.next_event_seq, messages_after=run.next_message_seq),
)
# Pick engines or a credit limit from the plan card's options before approving; approve the returned plan_version.
# run = client.prospecting.update_plan(run.run_id, ProspectingPlanSettings(plan_version=run.plan_version, max_spend_usd=25))
# 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().
Expand All @@ -462,8 +464,12 @@ The async client exposes the same methods with `await`. Starts and messages requ

`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.
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 whose search runs dry 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.

Intake: a run can pause on a `kind="question"` message whose `data` lists a few option fields (keys `company_activity`, `industry`, `geography`, `company_size`, `persona_roles`, `list_size`). Answer it with `client.prospecting.answer_intake(run.run_id, {"company_activity": IntakeAnswer(values=["sell"]), "geography": IntakeAnswer(other="Ohio")}, idempotency_key=...)`: `values` are the option values you picked and `other` is free text. `IntakeAnswer` comes from `discolike.requests`. Invalid answers return 422; an answer sent after the card was superseded returns 409. Typing free text with `message()` instead still works.

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.

Run controls: `update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan before approval. `contact_integration_id`, `validation_integration_id` and `search_provider_id` take an option id from the plan message's `contact_engine`, `company_check_engine` and `search_provider` (`data.contact_engine.options` and so on); omitted fields keep their choice, `search_provider_id="none"` skips web research, and `max_spend_usd` sets the most the run may spend on DiscoLike records and per-request fees, in USD at your plan's rates (`0` removes the limit; a plan with no per-record price is a 422). The plan is re-estimated and posted again at a new `plan_version`, and that is the version to approve. An id outside the plan's options is a 422; a run that is not awaiting approval, or a stale `plan_version`, is a 409. With no engine chosen, contact research runs on DiscoLike Groove when you have a search provider and finds indexed contacts only when you have none. New `stop_reason` values, all plain strings: `candidate_limit` (the run reached its candidate cap; raise `max_candidates` or ask for fewer companies), `credit_limit` (the run stopped at its spending limit and kept its results), and `candidates_exhausted` now means only that the search ran dry. An automatic candidate cap may grow once from the run's measured yield; a `max_candidates` you set never does. Interrupted indexed contact searches are rerun rather than skipped.

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.
1 change: 1 addition & 0 deletions packages/discolike-cli/tests/test_sdk_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"ContactsCountParams": frozenset({"icp_text"}),
"ContactFilters": frozenset({"icp_text"}),
"FindEmailBatchRequest": frozenset({"source_query_id", "refs", "round"}),
"ProspectingMessageRequest": frozenset({"intake"}),
}
# ``discolike bulk`` takes the full vocabulary through --params-file / --param and manages the paging
# fields itself; its flags are the handful a volume run needs, not one per SDK field.
Expand Down
2 changes: 2 additions & 0 deletions packages/discolike/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ Two errors are specific to the native engine: a 400 `ValidationError` when the I

`wait()` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`; timeout stops local polling only. Use `message(run_id, ProspectingMessageRequest(text="..."), idempotency_key="...")` to steer or answer a question, and `get(run_id, ProspectingGetParams(events_after=..., messages_after=...))` for new events and replies. `list(ProspectingListParams(limit=20))` lists recent organization runs, up to 50. `rename(run_id, ProspectingRunUpdate(title="..."))` retitles a run in any status and returns its `list()` summary (titles are 1-80 characters after trimming; result lists it already saved keep their names). `cancel(run_id)` stops a run and returns it; `delete(run_id)` cancels an active run, then removes it so `list()` omits it and `get()` raises `NotFoundError` (charges already incurred stay). The async client has the same methods with `await`. Import these request models from `discolike.requests`.

`update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan's `contact_integration_id`, `validation_integration_id`, `search_provider_id` (`"none"` skips web research) and `max_spend_usd` (a USD spending limit on records and per-request fees at the plan's rates; `0` removes it) using option ids from the plan message's `contact_engine`, `company_check_engine` and `search_provider`, then returns the run with a re-estimated plan at a new `plan_version` to approve. An unknown option id is a 422; a run not awaiting approval or a stale `plan_version` is a 409. `stop_reason` also reports `candidate_limit` and `credit_limit`; `candidates_exhausted` means only that the search ran dry.

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 continues the run with a notice. Only if the re-pilot fit is still under 20% does it stop 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 in that case. If sharpening itself fails, the run continues on the original criteria. 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.

Omit target counts to infer them from the brief; explicit values override the text. Work caps default to automatic (`0`). Partial results and `saved_query_id` remain available after stopping. Large results are split into several saved contact lists rather than being cut off; `saved_query_ids` carries every list for the run in order, with `saved_query_id` always the first entry, and the parts are final once the run reaches a terminal status. Customer integration charges apply; work caps do not cap provider dollar spend.
Expand Down
57 changes: 53 additions & 4 deletions packages/discolike/src/discolike/_generated/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -2915,10 +2915,6 @@ class ProspectingListParams(DiscolikeRequest):
] = None


class ProspectingMessageRequest(DiscolikeRequest):
text: Annotated[str, Field(max_length=4000, min_length=1, title="Text")]


class ProspectingBrief(DiscolikeRequest):
brief: Annotated[str, Field(max_length=4000, min_length=10, title="Brief")]
domains: Annotated[list[str] | None, Field(max_length=1000, title="Domains")] = None
Expand Down Expand Up @@ -2956,6 +2952,42 @@ class ProspectingGetParams(DiscolikeRequest):
messages_after: Annotated[int | None, Field(ge=0, title="Messages After")] = 0


class ProspectingPlanSettings(DiscolikeRequest):
plan_version: Annotated[int, Field(ge=1, title="Plan Version")]
contact_integration_id: Annotated[
str | None,
Field(
description="Contact research engine: an option id from the plan's contact_engine.",
max_length=128,
title="Contact Integration Id",
),
] = None
validation_integration_id: Annotated[
str | None,
Field(
description="Company check engine: an option id from the plan's company_check_engine.",
max_length=128,
title="Validation Integration Id",
),
] = None
search_provider_id: Annotated[
str | None,
Field(
description="Search provider: an option id from the plan's search_provider; 'none' skips web research.",
max_length=128,
title="Search Provider Id",
),
] = None
max_spend_usd: Annotated[
float | None,
Field(
description="Most the run may spend on DiscoLike records and per-call fees, in USD at your plan's rates, before it stops; 0 removes the limit.",
ge=0.0,
title="Max Spend Usd",
),
] = None


class ProspectingRunUpdate(DiscolikeRequest):
title: Annotated[str, Field(max_length=80, min_length=1, title="Title")]

Expand Down Expand Up @@ -3179,6 +3211,11 @@ class BulkContactMatchQueryItem(DiscolikeRequest):
] = None


class IntakeAnswer(DiscolikeRequest):
values: Annotated[list[str] | None, Field(title="Values")] = None
other: Annotated[str | None, Field(max_length=200, title="Other")] = None


class BulkContactMatchRequest(DiscolikeRequest):
queries: Annotated[
list[BulkContactMatchQueryItem],
Expand Down Expand Up @@ -3207,3 +3244,15 @@ class ProspectingApproveRequest(DiscolikeRequest):
checkpoints: Literal["ask", "auto"] | None = None
seed_segments: Annotated[list[int] | None, Field(min_length=1, title="Seed Segments")] = None
segment: Annotated[bool | None, Field(title="Segment")] = None


class ProspectingMessageRequest(DiscolikeRequest):
text: Annotated[str | None, Field(max_length=4000, min_length=1, title="Text")] = None
Comment on lines +3249 to +3250

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Empty messages pass validation

Both text and intake are optional, so ProspectingMessageRequest() passes validation and message() sends {}. Previously, a missing message was rejected locally. This now makes an unnecessary API request and leaves the server to reject it. Please require at least one field and test that case.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/discolike/src/discolike/_generated/requests.py
Line: 3249-3250

Comment:
**Empty messages pass validation**

Both `text` and `intake` are optional, so `ProspectingMessageRequest()` passes validation and `message()` sends `{}`. Previously, a missing message was rejected locally. This now makes an unnecessary API request and leaves the server to reject it. Please require at least one field and test that case.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

intake: Annotated[
dict[
Literal["company_activity", "industry", "geography", "company_size", "persona_roles", "list_size"],
IntakeAnswer,
]
| None,
Field(title="Intake"),
] = None
4 changes: 4 additions & 0 deletions packages/discolike/src/discolike/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from discolike._generated.requests import DiscoverParams
from discolike._generated.requests import FindEmailBatchRequest
from discolike._generated.requests import FindEmailRequest
from discolike._generated.requests import IntakeAnswer
from discolike._generated.requests import LLMProviderCreateRequest
from discolike._generated.requests import LLMProviderUpdateRequest
from discolike._generated.requests import MatchBulkParams
Expand All @@ -33,6 +34,7 @@
from discolike._generated.requests import ProspectingGetParams
from discolike._generated.requests import ProspectingListParams
from discolike._generated.requests import ProspectingMessageRequest
from discolike._generated.requests import ProspectingPlanSettings
from discolike._generated.requests import ProspectingRunUpdate
from discolike._generated.requests import QueriesListParams
from discolike._generated.requests import SaveResultsRequest
Expand Down Expand Up @@ -67,6 +69,7 @@
"DiscoverParams",
"FindEmailBatchRequest",
"FindEmailRequest",
"IntakeAnswer",
"LLMProviderCreateRequest",
"LLMProviderUpdateRequest",
"MatchBulkParams",
Expand All @@ -76,6 +79,7 @@
"ProspectingGetParams",
"ProspectingListParams",
"ProspectingMessageRequest",
"ProspectingPlanSettings",
"ProspectingRunUpdate",
"QueriesListParams",
"SaveResultsRequest",
Expand Down
Loading
Loading