# Audience Discovery

<!-- BEGIN GENERATED: guide-map -->
## Guide map

Read only the section needed for the next decision. Line ranges are exact
for this generated runtime target.

| Section | What it covers | Lines |
| --- | --- | --- |
| [Purpose](#purpose) | Scope, ownership, and the central operating question. | 40-53 |
| [Use This When](#use-this-when) | Tasks and conditions that belong in this guide. | 54-67 |
| [Do Not Use This For](#do-not-use-this-for) | Boundaries and the correct routes for excluded work. | 68-93 |
| [Mental Model](#mental-model) | Concepts and state boundaries needed for correct operation. | 94-112 |
| [Preflight](#preflight) | State, authority, inputs, and readiness to verify before execution. | 113-145 |
| [Source Selection](#source-selection) | Operating guidance and contracts for this section. | 146-165 |
| [Keyword + LinkedIn Company URL Filters Are Noisy — Qualify After](#keyword--linkedin-company-url-filters-are-noisy--qualify-after) | `vf.sources.jobs.find_jobs(company_linkedin_urls=[...], search_query="...")` and the keyword-by-keyword variant (`job_title_tags=["X", "Y", "Z"]` over the sa... | 166-206 |
| [Risk And Side Effects](#risk-and-side-effects) | Effects, approvals, cost, privacy, and retry boundaries. | 207-218 |
| [Canonical Operating Pattern](#canonical-operating-pattern) | The normal end-to-end execution sequence. | 219-233 |
| [Core SDK Surfaces](#core-sdk-surfaces) | Exact source-backed callable contracts owned by this guide. | 234-251 |
| [Data Contracts](#data-contracts) | Required identities, fields, evidence, handles, and durable shapes. | 252-262 |
| [Examples](#examples) | Representative usage patterns and code. | 263-432 |
| [Verification](#verification) | Evidence required before reporting success. | 433-446 |
| [Troubleshooting](#troubleshooting) | Failure diagnosis and corrective routes. | 447-460 |
| [Related References](#related-references) | Adjacent boundaries and when to cross them. | 461-477 |
<!-- END GENERATED: guide-map -->

> **STOP — before any `vf.sources.*` / `vf.clay.*` /
> `vf.host.crunchbase.*` call below: confirm with the user that this is a play,
> not a one-off pull.** Audience asks like "get all YC W26", "pull
> contacts at these accounts", "find seed-stage SaaS" look simple but are
> play-shaped — the user almost always wants to enrich / qualify /
> sequence later. Default is play. Ask: "I'd scaffold a play at
> `~/verbiflow/plays/<slug>` so the work is reproducible and you can
> enrich / qualify / sequence off it; or is this a true one-off?" Only
> skip the scaffold on explicit user confirmation, and call out once
> that credit attribution will be untagged. SKILL.md "Before ANY Data
> Fetch: Confirm Play Or One-Off" owns the full rule.

## Purpose

Use this guide to choose and run audience acquisition sources: people search,
people-at-company jobs, incubator/cohort source jobs, hiring-signal contact
discovery, and the first artifact handoff. It answers "where should the
audience come from?" and "which bounded SDK source job should create the first
candidate rows?"

Audience discovery does not own the whole GTM workflow. It should produce
reviewable candidate artifacts with preserved `job_id`, `artifact_id`, source
criteria, limits, counts, and sample rows. CRM suppression, enrichment,
qualification, row shaping, report design, sequence staging, publish, and send
belong to narrower references.

## Use This When

- The user asks to find people, accounts, companies, cohorts, or signals to
  build an outbound audience.
- You have a fixed company list or LinkedIn company URLs and need people at
  those accounts.
- You have broad ICP constraints such as title, company filters, locations, or
  past-company constraints and need a provider-backed people artifact.
- You need contacts from job-posting or hiring signals.
- You need incubator/cohort source options before launching a cohort source
  job.
- You are deciding whether CRM, LinkedIn, Clay, Crunchbase, public web, places,
  or workflow checkpointing is the right source owner.

## Do Not Use This For

- Ambiguous "source" classification by itself. Read `sources.md` first, then
  come here only for acquisition-source work.
- CRM schema, object, association, active-customer, deal, ownership, or
  suppression mechanics. Use `crm.md`.
- LinkedIn sends, connection requests, posts, relationship status, or detailed
  LinkedIn research mechanics. Use `linkedin.md`; publish/send still follows
  `safety.md`.
- Host-browser mechanics for Crunchbase, LinkedIn browser state,
  screenshots, or local evidence capture. Use `host_browser.md`,
  `crunchbase.md`, or `funding.md`. Clay uses its Public API and belongs in
  `clay.md`, not the host-browser path.
- Public web search/page extraction details. Use `web.md`.
- Geography containment over already-acquired row text. Use `geo.md`.
- Canonical email, profile, phone, domain, identity, company research, or
  resolver work. Use `enrichment.md`.
- Local `RowSet` or CSV transforms after rows already exist. Use
  `row_operations.md`.
- Artifact paging, job waiting, streaming, joins, or exports beyond the source
  job handoff. Use `artifacts_jobs.md`.
- Workflow source checkpointing, replay, freshness, object memory, or recurring
  windows. Use `workflow_sources.md`.
- Sequence source staging, promotion, publish, direct sends, or sequence copy.
  Use `sequences.md`, `sequence_authoring.md`, and `safety.md`.

## Mental Model

Audience discovery is acquisition, not enrichment and not activation.

The normal path is:

1. Decide the acquisition source class and source owner.
2. Bound the source job with explicit criteria, limits, and provider readiness.
3. Start the source job and preserve `job_id` immediately.
4. Wait or page through the job/artifact using `artifacts_jobs.md`.
5. Sample rows, inspect fields, and record counts.
6. Hand the candidate artifact to CRM suppression, enrichment, row operations,
   workflow checkpointing, reports, or sequence staging as separate visible
   steps.

Do not hide later pipeline steps inside acquisition helpers. A source job can
spend credits or call providers, but it should not silently enrich emails,
stage a sequence, publish, send, or purchase anything.

## Preflight

- **Confirm with the user: play or one-off?** Most audience asks ("get all
  YC W25 companies", "pull contacts at these accounts", "find seed-stage
  SaaS in NYC") look like quick data grabs but are play-shaped — the user
  almost always wants to enrich, qualify, or sequence off the result later.
  Default to play-shaped. Before any `vf.sources.*` /
  `vf.clay.*` / `vf.host.crunchbase.*` call, pause and ask: "this
  sounds play-shaped — I'd scaffold a play at `~/verbiflow/plays/<slug>`
  so the work is reproducible and you can enrich / qualify off it; or is
  this a genuine one-off pull?" Only skip play scaffolding when the user
  explicitly says one-off — and call out once that credit attribution
  will be untagged. The SKILL Starting Protocol owns the full rule; this
  is the audience-discovery-specific reminder.
- Verify auth and workspace identity with `auth.md` before provider work.
- Write down the audience goal in one sentence: target account kind, target
  person kind, source signal, geography, freshness, and disqualifiers.
- Pick the source class before calling SDK functions: fixed company list,
  broad people search, hiring signal, incubator/cohort, CRM universe,
  LinkedIn evidence, public web, places, or workflow checkpointed source.
- For fixed companies, make sure the people-at-company source has LinkedIn
  company URLs. `vf.sources.people.find_at_companies_start(...)` accepts
  `company_linkedin_urls`, `company_linkedin_urls_text`, or
  `company_linkedin_urls_csv_path`; it is not a raw-domain lookup function.
- Decide limits up front: `max_rows`, `max_items`, `max_per_company`, title
  filters, location filters, cohort option, job URLs, and exported page size.
- Print the criteria, limits, expected output columns, and whether the call can
  spend credits before launching a source job.
- If suppression needs CRM fields, fetch CRM schema/objects/associations first
  through `crm.md` and make suppression a separate row/artifact join.
- If the task should become repeatable, route to `workflow_composition.md` and
  `workflow_sources.md` before writing ad hoc cursor logic.

## Source Selection

| Situation | Prefer | Why |
| --- | --- | --- |
| Pasted LinkedIn company URLs or a CSV/text file of LinkedIn company URLs | `vf.sources.people.find_at_companies_start(...)` | Starts a bounded people-at-account source job and returns a `job_id` for paging/export. |
| Named companies or domains without LinkedIn company URLs | Resolve/verify account identifiers first through CRM, web, LinkedIn, Clay, or Crunchbase references | The people-at-company source is URL-oriented; do not pretend raw domains are equivalent unless the selected resolver produced LinkedIn company URLs. |
| Broad people ICP without fixed accounts | `vf.sources.people.targeted_search(...)` | Creates a durable people artifact from title/location/company constraints and optional past-company constraints. |
| Broad hiring-signal audience from active postings | `vf.sources.jobs.find_contacts(job_title_tags=..., ...)` | Searches job postings by title/location tags, then optionally finds contacts at discovered companies. This is broad discovery, not exact job URL/domain contact extraction. |
| Raw job postings (not people) by title/location/keyword | `vf.sources.jobs.find_jobs(job_title_tags=..., location_tags=..., search_query=...)` then `vf.rows.ai_analyze(mode="qualify", ...)` | Returns posting rows (company, URL, title, location, posted-at) instead of contacts. Uses the public-postings provider when no company filter is given. Keyword/title matches loosely — **always** qualify the result against the real ICP before using it as the audience (see "Keyword + LinkedIn Company URL Filters Are Noisy"). |
| Raw job postings at specific LinkedIn companies | `vf.sources.jobs.find_jobs(company_linkedin_urls=[any-size-list])` then `vf.rows.ai_analyze(mode="qualify", ...)` | Routes to the LinkedIn-jobs scraper. `company_linkedin_urls` accepts LinkedIn company URLs only (`https://www.linkedin.com/company/<slug>`); plain names/domains are rejected. Pass any size — the SDK chunks at `chunk_size=5` with `concurrency=30` internally; you do not need to batch yourself. **`job_title_tags` are split on whitespace and capped at 3 fuzzy roots** (`"customer success"` → `["customer","success"]`). Output is high-recall, low-precision — qualify with `vf.rows.ai_analyze(mode="qualify", ...)` immediately after. |
| Exact job URLs | Resolve/verify the companies first, then use people-at-company or enrichment paths | Neither `find_contacts` nor `find_jobs` accepts arbitrary job URLs. Do not pass `job_urls`, `company_domains`, or old `titles`. |
| Incubator or cohort source | `vf.sources.incubators.options(...)`, then `vf.sources.incubators.batch(...)` | Discovers valid source options before launching the selected cohort job. |
| Existing CRM universe, active-customer suppression, deal suppression, owner routing, or company-contact association logic | `crm.md` | CRM fields and graph semantics must be read from the CRM provider before acquisition or suppression decisions. |
| LinkedIn post/company/person evidence | `linkedin.md` | Exact LinkedIn research functions and status semantics belong there; sends/posts are not audience discovery. |
| Clay, Crunchbase, or recent funding discovery | `clay.md`, `crunchbase.md`, `funding.md`; Clay uses only `vf.clay.*` Public API calls | Provider refs own execution; host-browser preflight applies only where the provider reference explicitly requires it. |
| Public web, funding, launch, or product-news discovery | `web.md`, then `enrichment.md` or workflow composition as needed | Public web extraction and company research have separate policy, artifact, and credit semantics. |
| Local-business/place discovery | `places.md` | Places/reviews/geography are a separate provider source family. |
| Existing sequence source/audience inspection | `sequences.md` | Sequence source reads and staging are sequence operations, not audience acquisition. |
| Recurring daily/weekly audience source | `workflow_sources.md` | Workflow sources own checkpointing, freshness, object memory, and replay behavior. |

## Keyword + LinkedIn Company URL Filters Are Noisy — Qualify After

`vf.sources.jobs.find_jobs(company_linkedin_urls=[...], search_query="...")`
and the keyword-by-keyword variant
(`job_title_tags=["X", "Y", "Z"]` over the same company list) both go
through LinkedIn's loose match logic. Returned rows routinely include:

- postings whose title only loosely matches the keyword (e.g. a "Software
  Engineer" listing returned for `search_query="Solutions Engineer"`),
- postings from companies that share a slug/word with one of the input
  company URLs but are not the target,
- old/closed postings re-surfaced because they still match the keyword
  cosmetically,
- finance/legal/exec-assistant roles that hit the title keyword
  cosmetically but are not the role family (Investor, Advisor, "Executive
  Assistant to CEO" all slip through if the rubric isn't explicit).

The provider gives a high recall, low precision result. **Treat the raw
result as a candidate set, never as the final audience.** The recommended
shape is:

1. Run `vf.sources.jobs.find_jobs(...)` with the broadest reasonable
   keywords and company filter — accept the noise. Pass any size company
   list; the SDK chunks at `chunk_size=5` with `concurrency=30` internally.
   `job_title_tags` are split on whitespace into fuzzy root words and
   capped at 3 roots — `["customer success manager"]` becomes
   `["customer", "success", "manager"]` (then capped). The cap is logged
   so you can see which roots were dropped.
2. Immediately run `vf.rows.ai_analyze(mode="qualify", rubric=..., fields=...)`
   over the returned rows with an LLM rubric that checks the **actual**
   title/seniority/function against the user's ICP. The rubric should
   reject postings whose title doesn't match the role family (not just
   the keyword), postings from wrong companies, outdated postings, and
   adjacent-role noise (Investor/Advisor/Executive Assistant).
3. Print the pre-qualify vs post-qualify counts and a sample of rejected
   rows so the user can audit the rubric before scaling.

Same rule applies to `vf.sources.people.find_at_companies_start(...)` when
`job_titles`/`job_title_tags` is broad — qualify before treating the
output as the audience.

## Risk And Side Effects

| Operation | Risk | Rule |
| --- | --- | --- |
| Local source planning and criteria review | local | Safe after inspecting inputs. |
| CRM, LinkedIn, web, host, or provider status reads | platform read | Print workspace/provider identity when it affects correctness. |
| Source jobs under `vf.sources.people.*`, `vf.sources.incubators.*`, or `vf.sources.jobs.*` | credit/provider job | Print criteria and limits first; preserve `job_id`, `artifact_id`, counts, and sample rows. |
| Host-browser source work | local/provider browser action | Route to host docs; saved browser state and capture manifests are not interchangeable with Bridge source jobs. |
| Enrichment, qualification, or company research after acquisition | credit/provider job | Route to `enrichment.md` or `row_operations.md`; keep it visible as a separate step. |
| Sequence staging/promotion | sequence staging | Route to `sequences.md`; staging is not source discovery. |
| Publish/send/purchase | customer-visible or billing mutation | Requires explicit confirmation immediately before the call. |

## Canonical Operating Pattern

1. State the audience hypothesis, target entity, and source class.
2. Resolve workspace and provider readiness.
3. Select the acquisition function or route to the owning reference.
4. Launch one bounded source job with visible criteria and limits.
5. Preserve the returned `PendingJob.job_id`; after waiting, preserve any
   `artifact_id`.
6. Inspect a small page or export using `artifacts_jobs.md`; do not rerun the
   source job just to view rows.
7. Print candidate counts, sample rows, missing key fields, and the next
   handoff reference.
8. Only then enrich, qualify, suppress, report, checkpoint, or stage through
   the owning reference.

## Core SDK Surfaces

<!-- BEGIN GENERATED: core-sdk-surfaces -->
| Function | Use When | Inputs | Output And Handles To Preserve | Side Effect | Source |
| --- | --- | --- | --- | --- | --- |
| `vf.capabilities.get` | Read one live backend Growth capability policy row by canonical id. | <strong>Required</strong>: `capability_id` (str; required): Backend capability id to execute, such as email.enrich or company.profile.fetch.<br><strong>Optional</strong>: none | `tool_capability` payload returned by the SDK operation. Inspect the documented function reference and `raw`/dictionary fields when debugging provider-specific data. | read | installed SDK: `verbiflow/capabilities.py:80-97` |
| `vf.capabilities.list` | Inspect live backend Growth capability policy for advanced audit/debug use. | <strong>Required</strong>: none<br><strong>Optional</strong>: `capability_id` (str \| None; optional; default `None`): Backend capability id to execute, such as email.enrich or company.profile.fetch.; `category` (str \| None; optional; default `None`): Optional capability/category filter.; `limit` (int \| None; optional; default `None`): Maximum number of records, preview rows, or results to request.; `mode` (str \| None; optional; default `None`): Mode parameter for `capabilities.list`.; `query` (str \| None; optional; default `None`): Search query text. | `dict` with backend capability ids, categories, policy, and schemas. | read | installed SDK: `verbiflow/capabilities.py:13-51` |
| `vf.capabilities.search` | Search live backend Growth capability policy for advanced audit/debug use. | <strong>Required</strong>: `query` (str; required): Search query text.<br><strong>Optional</strong>: `category` (str \| None; optional; default `None`): Optional capability/category filter.; `limit` (int \| None; optional; default `10`): Maximum number of records, preview rows, or results to request.; `mode` (str \| None; optional; default `None`): Mode parameter for `capabilities.search`. | `dict` with backend capability ids, categories, policy, and schemas. | read | installed SDK: `verbiflow/capabilities.py:53-78` |
| `vf.sources.incubators.batch` | Use to discover incubator source options or start incubator-backed company/contact source jobs. | <strong>Required</strong>: `incubator_name` (string; required): Incubator/source kind name to list or run.<br><strong>Optional</strong>: `artifact_label` (string; optional): Human-readable label for the artifact created by the backend job.; `batch_id` (string; optional): Incubator/source batch id selected from `sources.incubators.options`.; `batch_ids` (Sequence[str] \| None; optional; default `None`): Multi-batch alternative to batch_id.; `limit` (integer; optional): Maximum number of records, preview rows, or results to request.; `max_companies` (int \| None; optional; default `None`): Max companies returned (default 1000, server cap).; `max_contacts` (int \| None; optional; default `None`): Total people cap when result_mode='people' (default 500).; `max_per_company` (int \| None; optional; default `None`): Per-company people cap when result_mode='people' (default 3).; `poll_interval` (float; optional; default `5.0`): Seconds between job status polls.; `result_mode` (companies \| people; optional; default `'companies'`): What the source job emits: 'companies' returns one row per company in the batch (default); 'people' fans out to contacts at each company using target_title_tags / max_per_company.; `target_title_tags` (Sequence[str] \| None; optional; default `None`): Title tags for the people-search fanout when result_mode='people'. Ignored when result_mode='companies'.; `timeout` (float; optional; default `900.0`): Maximum seconds to wait before returning the latest observed state.; `wait` (bool \| int; optional; default `False`): If false, return immediately with a job/status handle; if true or a timeout integer, poll until completion or timeout. | `PendingJob`: `job_id` immediately; wrap with `vf.jobs.result(pending).wait(...)`, inspect rows/artifact, and then route enrichment, row shaping, workflow checkpointing, or sequence staging through the owning reference. Preserve `artifact_id` when present. | artifact_job | installed SDK: `verbiflow/sources.py:291-421` |
| `vf.sources.incubators.options` | Use to discover incubator source options or start incubator-backed company/contact source jobs. | <strong>Required</strong>: none<br><strong>Optional</strong>: `incubator_name` (str \| None; optional; default `None`): Incubator/source kind name to list or run. | `dict` with available incubator source kinds or batches. | read | installed SDK: `verbiflow/sources.py:272-289` |
| `vf.sources.jobs.find_contacts` | Use for broad hiring-signal discovery from job-posting title/location tags. It does not constrain work to exact job URLs or company domains. | <strong>Required</strong>: `job_title_tags` (array; required): Title tags used by the selected source. For `sources.jobs.find_contacts`, these are job-posting search terms, not contact titles.<br><strong>Optional</strong>: `days` (integer; optional): Days parameter for `sources.jobs.find_contacts`.; `include_hiring_manager` (boolean; optional): Include hiring manager parameter for `sources.jobs.find_contacts`.; `location_tags` (array; optional): Normalized location tags for people search targeting.; `max_pages` (integer; optional): Max pages parameter for `sources.jobs.find_contacts`.; `max_per_company` (integer; optional): Maximum people to return for each input company.; `max_rows` (integer; optional): Maximum rows to upload, process, or return for this call.; `target_title_tags` (array; optional): Optional contact titles to search at companies discovered from matching job postings.; `wait` (boolean; optional): If false, return immediately with a job/status handle; if true or a timeout integer, poll until completion or timeout. | `PendingJob`: `job_id` immediately; wrap with `vf.jobs.result(pending).wait(...)`, inspect rows/artifact, and then route enrichment, row shaping, workflow checkpointing, or sequence staging through the owning reference. Preserve `artifact_id` when present.<br><strong>Common next calls</strong>: `vf.jobs.result`, `vf.artifacts.inspect`, `vf.artifacts.export_csv` | artifact_job | installed SDK: `verbiflow/sources.py:428-534` |
| `vf.sources.jobs.find_jobs` | Use for broad hiring-signal discovery from job-posting title/location tags. It does not constrain work to exact job URLs or company domains. | <strong>Required</strong>: none<br><strong>Optional</strong>: `chunk_size` (int; optional; default `5`): Companies per upstream call. The provider's company-scoped scraper silently dropped 48/47 companies above this size in production; 5 is the empirical safe ceiling.; `company_linkedin_urls` (Sequence[str] \| None; optional; default `None`): Optional company filter — any size. Must be LinkedIn company URLs (https://www.linkedin.com/company/<slug>). The SDK chunks the list internally (default chunk_size=5, concurrency=30) and fans out parallel upstream calls. When set, the company-scoped LinkedIn-jobs scraper is used; otherwise the public-postings provider runs.; `concurrency` (int; optional; default `30`): Maximum chunks submitted in parallel.; `days` (int \| None; optional; default `None`): Optional posting freshness window in days, 0-365.; `job_title_tags` (Sequence[str] \| None; optional; default `None`): Job-posting search title tags. Multi-word entries are split on whitespace into fuzzy root words and capped at 3 roots — 'customer success manager' becomes ['customer','success','manager']. Three broad roots reliably outperform many narrow phrases on the upstream providers.; `location_tags` (Sequence[str] \| None; optional; default `None`): Optional job-posting location tags.; `max_items` (int \| None; optional; default `200`): Maximum job-posting rows per upstream call.; `max_pages` (int; optional; default `5`): Maximum provider posting pages to scan (public-postings path).; `max_rows` (int \| None; optional; default `None`): Back-compat alias for max_items. max_items wins if both are set.; `search_query` (str \| None; optional; default `None`): Optional free-text query. The company-scoped path passes this as an additional keyword filter; the public-postings path appends it to each title-tag query.; `wait` (bool \| int; optional; default `False`): When true or an integer timeout, poll each returned job before returning. | `PendingJob`: `job_id` immediately; wrap with `vf.jobs.result(pending).wait(...)`, inspect rows/artifact, and then route enrichment, row shaping, workflow checkpointing, or sequence staging through the owning reference. Preserve `artifact_id` when present.<br><strong>Common next calls</strong>: `vf.jobs.wait_many`, `vf.jobs.result`, `vf.rows.ai_analyze`, `vf.artifacts.export_csv` | artifact_job | installed SDK: `verbiflow/sources.py:550-744` |
| `vf.sources.people.export_find_at_companies_csv` | Use to create or page people-search audiences from companies, titles, locations, or pasted account lists. | <strong>Required</strong>: `job_id` (str; required): Backend job id returned by a start/search/enrichment/qualification call.<br><strong>Optional</strong>: `extra` (Mapping[str, object] \| None; optional; default `None`): Advanced provider/backend options passed through deliberately; prefer typed args when available.; `output_path` (str \| Path \| None; optional; default `None`): Absolute local path where the SDK should write the downloaded/exported file. | `dict` with `csv_path`, `bytes_written`, `job_id`, and backend export metadata. | artifact_job | installed SDK: `verbiflow/sources.py:232-265` |
| `vf.sources.people.find_at_companies_page` | Use to create or page people-search audiences from companies, titles, locations, or pasted account lists. | <strong>Required</strong>: `job_id` (str; required): Backend job id returned by a start/search/enrichment/qualification call.<br><strong>Optional</strong>: `page` (int; optional; default `1`): One-based page number for page-style APIs.; `page_size` (int; optional; default `50`): Number of rows/results per page. | `dict` with people results, pagination metadata, and job status.<br><strong>Common next calls</strong>: `vf.sources.people.export_find_at_companies_csv` | read | installed SDK: `verbiflow/sources.py:207-230` |
| `vf.sources.people.find_at_companies_start` | Use when account inputs should become a people-search job across titles and locations. | <strong>Required</strong>: none<br><strong>Optional</strong>: `company_linkedin_urls` (Sequence[str] \| None; optional; default `None`): Canonical LinkedIn company URLs supplied inline.; `company_linkedin_urls_csv_path` (str \| Path \| None; optional; default `None`): Absolute CSV or text file path containing canonical LinkedIn company URLs.; `company_linkedin_urls_text` (str \| None; optional; default `None`): Newline-delimited canonical LinkedIn company URLs.; `extra` (Mapping[str, object] \| None; optional; default `None`): Advanced provider/backend options passed through deliberately; prefer typed args when available.; `job_title_tags` (Sequence[str] \| None; optional; default `None`): Title tags used by the selected source. For `sources.jobs.find_contacts`, these are job-posting search terms, not contact titles.; `job_titles` (Sequence[str] \| None; optional; default `None`): Concrete job titles to search for, such as VP Engineering or Head of Product.; `location_tags` (Sequence[str] \| None; optional; default `None`): Normalized location tags for people search targeting.; `locations` (Sequence[str] \| None; optional; default `None`): Locations to constrain people/company search.; `max_items` (int \| None; optional; default `None`): Maximum total people/items to return from the source job.; `max_per_company` (int \| None; optional; default `None`): Maximum people to return for each input company.; `max_rows` (int \| None; optional; default `None`): Maximum rows to upload, process, or return for this call.; `search_query` (str \| None; optional; default `None`): Search query parameter for `sources.people.find_at_companies_start`. | `PendingJob`: `job_id` immediately; wrap with `vf.jobs.result(pending).wait(...)`, inspect rows/artifact, and then route enrichment, row shaping, workflow checkpointing, or sequence staging through the owning reference. Preserve `artifact_id` when present.<br><strong>Common next calls</strong>: `vf.jobs.result`, `vf.jobs.stream_rows`, `vf.artifacts.inspect` | artifact_job | installed SDK: `verbiflow/sources.py:99-205` |
| `vf.sources.people.targeted_search` | Use to create or page people-search audiences from companies, titles, locations, or pasted account lists. | <strong>Required</strong>: none<br><strong>Optional</strong>: `company_linkedin_urls` (Sequence[str] \| None; optional; default `None`): Company LinkedIn URLs to use as account inputs.; `company_linkedin_urls_csv_path` (str \| Path \| None; optional; default `None`): Absolute CSV/text path containing company LinkedIn URLs.; `extra` (Mapping[str, object] \| None; optional; default `None`): Advanced provider/backend options passed through deliberately; prefer typed args when available.; `job_title_tags` (Sequence[str] \| None; optional; default `None`): Title tags used by the selected source. For `sources.jobs.find_contacts`, these are job-posting search terms, not contact titles.; `location_tags` (Sequence[str] \| None; optional; default `None`): Normalized location tags for people search targeting.; `max_rows` (int; optional; default `500`): Maximum rows to upload, process, or return for this call.; `past_company_linkedin_urls` (Sequence[str] \| None; optional; default `None`): URL value(s) for past company linkedin.; `past_job_title_tags` (Sequence[str] \| None; optional; default `None`): Past job title tags parameter for `sources.people.targeted_search`.; `wait` (bool \| int; optional; default `False`): If false, return immediately with a job/status handle; if true or a timeout integer, poll until completion or timeout. | `PendingJob`: `job_id` immediately; wrap with `vf.jobs.result(pending).wait(...)`, inspect rows/artifact, and then route enrichment, row shaping, workflow checkpointing, or sequence staging through the owning reference. Preserve `artifact_id` when present.<br><strong>Common next calls</strong>: `vf.jobs.get`, `vf.artifacts.get`, `vf.rows.ai_analyze` | artifact_job | installed SDK: `verbiflow/sources.py:22-97` |
<!-- END GENERATED: core-sdk-surfaces -->

## Data Contracts

| Contract | Required Shape |
| --- | --- |
| Source criteria | Human-readable audience hypothesis, source class, filters, locations, titles, limits, and provider/source name. |
| Fixed-company inputs | Prefer LinkedIn company URLs for `find_at_companies_start`; preserve original company name/domain/CRM id columns separately for later joins. |
| Job handles | Preserve `job_id` as soon as a source job starts. After wait/export, preserve `artifact_id`, row count, status, and output path if present. |
| Candidate rows | Keep source evidence columns, person/company identifiers, provider URLs, domains, titles, locations, and provenance. Do not drop source keys before enrichment or CRM joins. |
| Preview output | Show bounded sample rows and missing-field coverage. Do not claim an audience is ready from counts alone. |
| Handoff | State the next reference and exact handoff object: artifact id, RowSet, CSV path, workflow table, or sequence source candidate artifact. |

## Examples

### People At Known Accounts

```python
import verbiflow as vf

vf.auth.ensure_logged_in()

job = vf.sources.people.find_at_companies_start(
    company_linkedin_urls=[
        "https://www.linkedin.com/company/example",
    ],
    job_titles=["VP Engineering", "Head of Product"],
    locations=["San Francisco Bay Area"],
    max_per_company=5,
    max_items=100,
)

result = vf.jobs.result(job).wait(timeout=300)
rows = result.rows(limit=25)
print(result.job_id, result.artifact_id, len(rows), rows[:3])
```

Use `artifacts_jobs.md` after this point for artifact paging/export. Use
`enrichment.md`, `crm.md`, `row_operations.md`, or `sequences.md` for the next
operation.

### Hiring-Signal Contacts

```python
import verbiflow as vf

pending = vf.sources.jobs.find_contacts(
    job_title_tags=["GTM Engineer", "Growth Engineer"],
    location_tags=["United States", "United Kingdom"],
    target_title_tags=["Founder", "CEO", "Head of Talent"],
    max_rows=100,
    wait=False,
)
result = vf.jobs.result(pending).wait(timeout=300)
print(result.job_id, result.artifact_id, result.rows(limit=10))
```

This call broadens from posting title/location criteria to matching provider
job postings. It does not accept `job_urls`, `company_domains`, `titles`, or
`limit`. If the task starts from exact job URLs/domains, stop and resolve those
companies through web/CRM/LinkedIn/enrichment first, then use the fixed-company
people source.

### Job Postings: Credit Sizing And Title Filtering

`vf.sources.jobs.find_jobs(...)` and `vf.sources.jobs.find_contacts(...)`
are credit-metered per completed job-posting row at the live server-published
rate. The backend reserves capacity up to `max_rows` while the job is
active, then settles the final charge from the actual returned row count.
Consequently:

1. **Always pass `job_title_tags`.** It is the single biggest lever for
   keeping the actual row count relevant and bounded. An unfiltered
   broad query against either upstream provider will hit `max_rows`
   almost every time; a title-narrowed query usually returns the exact
   set you wanted and nothing else. Do not skip `job_title_tags` because
   "I want to see everything they're hiring for" — that scales credits,
   not signal.
2. **Size `max_rows` to what you actually expect, not the 200 default.**
   For a single-company / single-title pull, `max_rows=25` is often
   right. For a 5-company / 2-title sweep, `max_rows=100` is plenty.
   Only push `max_rows` toward 500 when the search is genuinely broad
   (e.g. nationwide, no company filter, single noisy title tag).

Read the response's `row_count` against `max_rows`. A result of 14 rows with
`max_rows=200` settles 14 row credits, not 200. Still tighten `max_rows` and
`job_title_tags` because `max_rows` bounds provider work, capacity reservation,
latency, and worst-case spend.

### Job Titles — 3 Max, Use Common Root Terms (Jobs Search Only)

**Scope: this rule applies only to the job-posting search functions
`vf.sources.jobs.find_contacts(...)` and `vf.sources.jobs.find_jobs(...)`.
It does NOT apply to people-search functions** like
`vf.sources.people.targeted_search(...)` or
`vf.sources.people.find_at_companies_start(...)` — those route through
different upstream providers where title filtering is not the runtime
bottleneck.

The job-posting functions **slow close-to-linearly with the number of
`job_title_tags`** — each title becomes its own query inside the
upstream LinkedIn-jobs scraper.

Stuffing in 8 specific variants (`"Support Engineer"`, `"Support
Specialist"`, `"Customer Support Lead"`, `"Customer Success Engineer"`,
`"Technical Support"`, ...) turns a fast search into a slow one and
*still* tends to miss variants you didn't think of.

**Rule: ≤3 tags per call. Prefer broad fuzzy root terms over specific
variants.** The upstream provider does substring/keyword matching, so
one root catches every reasonable variant:

| Broad root (use this) | Variants it catches (skip enumerating these) |
| --- | --- |
| `support` | Support Engineer, Support Specialist, Customer Support Lead, Technical Support, Support Manager, ... |
| `customer` | Customer Success Manager, Customer Experience Lead, Customer Operations, ... |
| `sales` | AE, BDR, SDR, Sales Engineer, Sales Manager, Account Executive, ... |
| `engineer` / `engineering` | Software Engineer, Staff Engineer, Engineering Manager, Platform Engineer, ... |
| `marketing` | Marketing Manager, Growth Marketing, Product Marketing, Demand Gen, ... |
| `data` | Data Engineer, Data Scientist, Data Analyst, Analytics Engineer, ... |

**When to pass more than one tag:** when the root over-matches (e.g.
`customer` would also pull Customer Operations roles when you specifically
want CS). Pass 2–3 disambiguating tags, never more.

**Smell test:** run a 25-row sample first (`max_rows=25`, `wait=False`,
then page). If the sample looks right, run the full job. If half is
irrelevant, narrow with one more specific tag — still capped at 3 total.

```python
# BAD — slow and incomplete:
vf.sources.jobs.find_contacts(
    job_title_tags=[
        "Support Engineer", "Support Specialist", "Customer Support Lead",
        "Customer Support Manager", "Technical Support Engineer",
        "Customer Experience Specialist", "Support Operations Lead",
    ],
)

# GOOD — one broad root catches all of those and runs ~7× faster:
vf.sources.jobs.find_contacts(job_title_tags=["support"])
```

A 10-tag search is almost always a mistake.

### Job Postings (Not Contacts)

```python
import verbiflow as vf

# Public-postings path: search by title (REQUIRED) + location. Keep
# max_rows tight — final credits follow returned rows, while this bounds work.
pending = vf.sources.jobs.find_jobs(
    job_title_tags=["Software Engineer"],  # always filter; billed per returned row
    location_tags=["New York", "California"],
    days=7,
    max_rows=100,  # bound work and worst-case spend
    wait=False,
)
result = vf.jobs.result(pending).wait(timeout=300)
print(result.job_id, result.artifact_id, result.rows(limit=10))

# Company-scoped path: postings at specific companies (LinkedIn URLs
# only). Still pass job_title_tags so irrelevant postings do not consume the
# provider budget or inflate actual returned-row charges.
pending = vf.sources.jobs.find_jobs(
    company_linkedin_urls=[
        "https://www.linkedin.com/company/google",
        "https://www.linkedin.com/company/microsoft",
    ],
    job_title_tags=["Software Engineer"],  # narrow before paying
    location_tags=["New York"],
    max_rows=50,  # 2 companies × ~25 relevant postings each
)
```

`find_jobs` returns posting rows (company name/domain/LinkedIn URL, job
posting URL, title, location, posted-at timestamp) — not contacts. The
`company_linkedin_urls` filter is URL-only; plain company names or domains
raise `ValidationError`. Pair with `sources.people.find_at_companies_start`
on the resulting companies if you also need people. `days` is optional; omit
it to search all postings.

## Verification

- The source class is explicit and the selected reference owns that class.
- Criteria and limits were printed before launching a credit/provider job.
- `job_id` is preserved for every source job, and `artifact_id` is preserved
  when the backend returns one.
- You inspected at least one bounded page or sample row set before moving on.
- Candidate rows include the fields required by the next step: company key,
  person key, source evidence, title/location, CRM ids, or provider URLs.
- Suppression, enrichment, qualification, staging, publish, or send did not
  happen implicitly inside audience acquisition.
- For recurring work, source-window/checkpoint semantics are documented in
  `workflow_sources.md` before relying on prior rows.

## Troubleshooting

| Symptom | Likely Cause | Fix |
| --- | --- | --- |
| `find_at_companies_start` gets no rows | Inputs are names/domains rather than LinkedIn company URLs, or title/location limits are too narrow | Resolve account identifiers first or loosen bounded criteria deliberately. |
| Candidate count looks high but rows are low quality | Source criteria are broad and enrichment/qualification has not happened yet | Treat the artifact as candidates only; route to `enrichment.md` and `row_operations.md`. |
| The agent wants to use `vf.linkedin.send_*` from discovery | Confusing research/acquisition with activation | Read `linkedin.md` for research and `safety.md` before any customer-visible mutation. |
| CRM suppression is missing | Discovery source ran before CRM fields/associations were fetched | Fetch CRM objects/associations via `crm.md`, then join/filter visibly. |
| Source job was rerun just to view rows | Artifact/job lifecycle was skipped | Use `vf.jobs.result(...)`, `vf.artifacts.*`, or `export_find_at_companies_csv(...)` through `artifacts_jobs.md`. |
| `sources.jobs.find_contacts` rejects `titles`, `job_urls`, or `company_domains` | Those fields implied an exact URL/domain mode that does not exist | Use `job_title_tags` for broad hiring-signal discovery, or resolve exact companies first and use people-at-company/enrichment. |
| `sources.jobs.find_jobs` rejects a `company_linkedin_urls` entry | The filter is LinkedIn-URL only — plain names/domains are not accepted | Resolve company LinkedIn URLs first (Clay, web, CRM), then re-call. To search without company filter, use `job_title_tags` and/or `search_query`. |
| Want hiring-signal **contacts** but called `find_jobs` | `find_jobs` returns postings, not people | Use `vf.sources.jobs.find_contacts` for people, or chain `find_jobs` → resolve companies → `sources.people.find_at_companies_start`. |
| A workflow reused stale candidate rows | Workflow checkpointing was mistaken for provider freshness | Read `workflow_sources.md`; reset/replay only with proof that cached rows still satisfy the current source contract. |

## Related References

| If you are doing this | Read next |
| --- | --- |
| Classifying ambiguous "source" language | `sources.md` |
| Fetching CRM fields, associations, active-customer/deal suppression, or owner routing | `crm.md` |
| Waiting on jobs, paging artifacts, streaming rows, exporting files, or preserving handles | `artifacts_jobs.md` |
| Deterministic local shaping after rows exist | `row_operations.md` |
| Email/profile/phone/domain/company/identity enrichment | `enrichment.md` |
| LinkedIn research, messages, posts, or relationship context | `linkedin.md` |
| Crunchbase/LinkedIn browser state, screenshots, funding, or local evidence | `host_browser.md`, `crunchbase.md`, `funding.md`, `web.md`; Clay Public API work stays in `clay.md` |
| Public web search or page extraction | `web.md` |
| Places/reviews/local-business discovery | `places.md` |
| Geography containment after row acquisition | `geo.md` |
| Designing repeatable workflow acquisition | `workflow_composition.md`, `workflow_sources.md` |
| Workflow run/report/sync behavior | `workflow_operations.md`, `workflow_reports.md`, `workflow_schedules.md` |
| Sequence source staging, copy, publish, or send | `sequence_authoring.md`, `sequences.md`, `safety.md` |
