# Private Equity Deal Sourcing: How PE and VC Teams Use LinkedIn

How private equity and VC firms source deals – the channels with the numbers, and two LinkedIn workflows: owner candidates for PE, stealth founders for VC.

Most deal-sourcing guides are written by the companies that sell deal-sourcing databases. This one starts with how private equity and venture firms actually say they find deals, then shows the part LinkedIn can carry – and the parts it cannot.

> **The short version.** Deal sourcing is how investors find companies to buy or back – through intermediaries such as investment banks and brokers, or through their own outreach to owners and founders. In a 2015 survey of private equity firms, about a third of closed deals were self-generated and about a third came from investment banks; venture capitalists surveyed in 2015–16 sourced mostly through their networks and their own outreach, with only about 10% arriving inbound. LinkedIn can support the research and outreach part of proprietary sourcing: finding owner and founder candidates, seeing how you are connected to them, and sending the first message from your own account. These workflows cover company and person research; for the financial and ownership picture, use a deal-sourcing database and your own diligence.

## What is deal sourcing?

Deal sourcing is the work of finding companies an investor might acquire or back, before the investment process starts. A deal arrives in one of two ways:

- **Intermediated** – an investment bank or broker runs a sale process and brings the company to many buyers, often as an auction.
- **Proprietary** – the investor finds the company and starts the conversation directly, before or instead of an auction. That is what **proprietary deal flow** means.

The word "proprietary" is used generously. In the largest academic survey of PE investors, firms considered almost 48% of their closed deals "proprietary in some way" – and the authors added that they had "no way of evaluating exactly what proprietary means nor can we validate the extent to which the deals truly are proprietary or advantaged" ([Gompers, Kaplan and Mukharlyamov, NBER](https://www.nber.org/papers/w21133)).

## How do private equity firms find deals?

The same survey of 79 PE investors, published in 2015, broke down where their closed deals came from:

| Source | Share of closed deals |
| --- | --- |
| Proactively self-generated | about 36% |
| Investment banks | 33% |
| Executive network | 8.6% |
| Deal brokers | 8.6% |
| Management | 7.4% |
| Other PE firms | 4.3% |

The funnel is long: "For every hundred opportunities, considered, the average PE investor deeply investigates fewer than 24, signs an agreement with fewer than 14 and closes on only 6." Smaller and younger firms "generally tend to source more proprietary deals" ([NBER w21133](https://www.nber.org/papers/w21133)).

Intermediated flow also leaks. Bain, citing Sutton Place Strategies data in 2017, found that "a typical firm will see only 18% of intermediated deals that might be relevant to the firm in its pipeline" ([Bain](https://www.bain.com/insights/a-two-pronged-approach-to-sourcing-more-pe-deals-forbes/)). These are historical surveys of source channels, not a current benchmark – but they explain why firms invest in their own outreach.

## How do VCs source deals?

A survey of 885 venture capitalists at 681 firms, run between November 2015 and March 2016, found that "most of the deal flow comes from the VCs' networks in some form or another" ([Gompers, Gornall, Kaplan and Strebulaev, NBER](https://www.nber.org/papers/w22587)):

- professional networks – over 30% of deals;
- referrals from other investors – 20%;
- referrals from existing portfolio companies – 8%;
- proactively self-generated – almost 30%;
- inbound from company management – only 10%.

"The average firm in our sample screens 200 companies and makes only four investments in a given year." Few deals come from founders who arrive with no connection at all.

## Where LinkedIn fits, and where it does not

| Job | LinkedIn, on your own account | Deal-sourcing database | Relationship CRM |
| --- | --- | --- | --- |
| Find companies by size, location, industry and hiring | Yes – company search and Sales Navigator account filters | Yes, with a fuller company universe | – |
| Verified financials, funding, ownership, registrations | Not part of these workflows – Sales Navigator has an annual revenue filter, but treat it as a screen, not a figure | Yes – Grata, PitchBook, Harmonic and others | – |
| Find the owner or founder candidate and their title | Yes – by title, to verify | Sometimes | – |
| See how you are connected | Your connection degree to each person; named mutual connections in the LinkedIn interface; TeamLink on Sales Navigator Advanced plans | – | Relationship intelligence, such as Affinity |
| Spot founders leaving notable employers | Profile changes, by comparing your own stored observations run to run | Harmonic-style departure and stealth tracking at scale | – |
| First message | Yes – from your own account | – | – |
| Pipeline and deal records | – | – | Yes |

Use LinkedIn for the person-level research and the first touch; use a database for the market map and the numbers.

## PE workflow: build an owner-candidate list

1. **Define the target.** Use company search filters – industry, headquarters location, company size – or Sales Navigator's account filters. Sales Navigator adds company headcount growth over the last 12 months and "Job opportunities", which shows "only those with open job opportunities posted on LinkedIn" ([LinkedIn Help](https://www.linkedin.com/help/sales-navigator/answer/a1452817)). Save the search, and its URL carries the same filters into [Linked API](/docs/action-nv-search-companies).
2. **Read each company page.** LinkedIn shows the employee count, the number of open jobs, the founding year, the website and the description ([company page data](/docs/action-st-open-company-page)). The employee count comes from LinkedIn members, not payroll. LinkedIn's own Premium company insights appear only when a company has "enough employees with complete profiles" ([LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a563146)), so small owner-run firms look thin.
3. **List the decision makers.** For each company you get each decision maker's name, headline, location and your connection degree to them. Mark the people whose titles say Owner, Founder, CEO or President as **owner candidates** – a title is a lead to verify, not proof of ownership.
4. **Check by hand in LinkedIn.**
   - Confirm ownership against your database or a company registry.
   - Look at named mutual connections and ask for an introduction.
   - On Sales Navigator Advanced plans, TeamLink shows prospects "who can be introduced to you through someone you know" ([LinkedIn Help](https://www.linkedin.com/help/sales-navigator/answer/a101027)).
5. **Send a first message that sounds like you.** A free account can add a personal note to only a few connection requests a month – LinkedIn's current Help says three, of up to 200 characters ([LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a563153)) – and Premium removes that cap. Owners are not leads in a sequence; as Scaliq's guide for M&A advisors puts it, over-automated outreach "poses a severe reputation risk in relationship-driven markets".

An illustrative prompt for an AI agent connected to your account:

> Find 50–200-person HVAC companies headquartered in Ohio with open jobs on LinkedIn. For each, list the decision makers whose titles say Owner, Founder, CEO or President, with my connection degree to each. Change nothing and send nothing.

## VC workflow: a stealth-founder watchlist

The signal is public and simple. In Harmonic's words, "A LinkedIn profile updated to "stealth" or to a vague title like "building something new" is the most explicit signal that someone is starting a company", and the strongest version is "a departure from a high-density employer combined with no public landing at a new one". Harmonic also names the false positives: "sabbaticals and ordinary job moves where the next role is not yet listed". The label also lags – as Poach puts it, "if someone lists "Stealth Startup" as their job on LinkedIn, it's already too late".

LinkedIn has no stealth setting of its own: Open to Work offers only all members, recruiters only or only you ([LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a507508)). The "Stealth Startup" company page that tens of thousands of members list as their employer is a community page created by members, and it points founders to Harmonic's sign-up form.

1. **Build the watchlist.** Search people by past company – the employers whose alumni you back – plus a title term, or save a Sales Navigator lead search with Past company and Changed jobs, which covers "leads who have changed jobs within the last 90 days" ([LinkedIn Help](https://www.linkedin.com/help/sales-navigator/answer/a1452817)). Keep each person's name and profile URL in `watchlist.json`.
2. **Re-check each profile on a schedule you own** – weekly is plenty.
3. **Compare with the last observation you stored, and list only what changed.**
4. **Review the list.** It is a list of profile changes, not a list of deals.

The script stores one observation per profile in `watchlist-state.json`: when it looked, the headline, the current employers and whether the profile shows a stealth label.

| Change | What it means |
| --- | --- |
| `stealth label at baseline` | First check, and the profile already says stealth or "building something new" |
| `stealth label added` | The label appeared since the last successful check |
| `no current role listed` | There were current employers last time and none now – a member can also hide or delete experience, so check before you read it as a departure |
| `current employer changed` | A previous current employer is gone and a different one appeared |
| `unknown` | The profile could not be fetched; the stored observation is kept for the next run |

```typescript
import { readFile, writeFile } from "node:fs/promises";
import LinkedApi, { LinkedApiError } from "@linkedapi/node";

const linkedapi = new LinkedApi({
  linkedApiToken: "your-linked-api-token",
  identificationToken: "your-identification-token",
});

interface WatchedPerson {
  name: string;
  linkedinUrl: string;
}

interface Observation {
  observedAt: string;
  headline: string;
  currentEmployers: Array<string>;
  hasStealthLabel: boolean;
}

type Change =
  | "stealth label at baseline"
  | "stealth label added"
  | "no current role listed"
  | "current employer changed"
  | "unknown";

interface ReviewRow {
  name: string;
  linkedinUrl: string;
  change: Change;
  before: Observation | null;
  after: Observation | null;
  note: string;
}

const STEALTH_PATTERN = /stealth|building something new/i;

function normalize(value: string): string {
  return value
    .normalize("NFKC")
    .toLowerCase()
    .replace(/[^\p{L}\p{N}]/gu, "");
}

async function readJson<T>(path: string, fallback: T): Promise<T> {
  try {
    return JSON.parse(await readFile(path, "utf8"));
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      return fallback;
    }
    throw error;
  }
}

async function observe(linkedinUrl: string): Promise<Observation | { note: string }> {
  let data;
  try {
    const workflow = await linkedapi.fetchPerson.execute({
      personUrl: linkedinUrl,
      retrieveExperience: true,
    });
    const result = await linkedapi.fetchPerson.result(workflow.workflowId);
    if (result.errors.length > 0 || !result.data) {
      return { note: result.errors.map((error) => error.message).join("; ") };
    }
    data = result.data;
  } catch (error) {
    if (error instanceof LinkedApiError) {
      return { note: error.message };
    }
    throw error;
  }
  const currentRoles = (data.experiences ?? []).filter((experience) => experience.endTime === null);
  return {
    observedAt: new Date().toISOString(),
    headline: data.headline,
    currentEmployers: currentRoles
      .map((role) => role.companyName)
      .filter((name) => normalize(name) !== ""),
    hasStealthLabel:
      STEALTH_PATTERN.test(data.headline) ||
      currentRoles.some((role) => STEALTH_PATTERN.test(`${role.companyName} ${role.position}`)),
  };
}

function compare(before: Observation | undefined, after: Observation): Change | null {
  if (!before) {
    return after.hasStealthLabel ? "stealth label at baseline" : null;
  }
  if (after.hasStealthLabel && !before.hasStealthLabel) {
    return "stealth label added";
  }
  if (before.currentEmployers.length > 0 && after.currentEmployers.length === 0) {
    return "no current role listed";
  }
  const beforeSet = new Set(before.currentEmployers.map(normalize));
  const afterSet = new Set(after.currentEmployers.map(normalize));
  const hasLeftOne = [...beforeSet].some((employer) => !afterSet.has(employer));
  const hasJoinedOne = [...afterSet].some((employer) => !beforeSet.has(employer));
  return hasLeftOne && hasJoinedOne ? "current employer changed" : null;
}

async function main(): Promise<void> {
  const watchlist = await readJson<Array<WatchedPerson>>("watchlist.json", []);
  const state = await readJson<Record<string, Observation>>("watchlist-state.json", {});
  const review: Array<ReviewRow> = [];

  for (const person of watchlist) {
    const before = state[person.linkedinUrl];
    const after = await observe(person.linkedinUrl);
    if ("note" in after) {
      review.push({
        ...person,
        change: "unknown",
        before: before ?? null,
        after: null,
        note: after.note,
      });
      continue;
    }
    const change = compare(before, after);
    state[person.linkedinUrl] = after;
    if (change) {
      review.push({ ...person, change, before: before ?? null, after, note: "" });
    }
  }

  await writeFile("review.json", JSON.stringify(review, null, 2));
  await writeFile("watchlist-state.json", JSON.stringify(state, null, 2));
  console.log(`${review.length} of ${watchlist.length} people to review`);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});
```

```python
import json
import re
import unicodedata
from datetime import datetime, timezone
from pathlib import Path

from linkedapi import FetchPersonParams, LinkedApi, LinkedApiConfig, LinkedApiError

linkedapi = LinkedApi(
    LinkedApiConfig(
        linked_api_token="your-linked-api-token",
        identification_token="your-identification-token",
    )
)

STEALTH_PATTERN = re.compile(r"stealth|building something new", re.IGNORECASE)
STATE_FILE = Path("watchlist-state.json")


def normalize(value: str | None) -> str:
    text = unicodedata.normalize("NFKC", value or "").lower()
    return re.sub(r"[\W_]", "", text)


def observe(linkedin_url: str) -> dict:
    try:
        workflow = linkedapi.fetch_person.execute(
            FetchPersonParams(person_url=linkedin_url, retrieve_experience=True)
        )
        result = linkedapi.fetch_person.result(workflow.workflow_id)
    except LinkedApiError as error:
        return {"note": error.message}
    profile = result.data
    if result.errors or profile is None:
        return {"note": "; ".join(error.message for error in result.errors)}

    current_roles = [
        experience for experience in profile.experiences or [] if experience.end_time is None
    ]
    return {
        "observedAt": datetime.now(timezone.utc).isoformat(),
        "headline": profile.headline or "",
        "currentEmployers": [
            role.company_name for role in current_roles if normalize(role.company_name)
        ],
        "hasStealthLabel": bool(STEALTH_PATTERN.search(profile.headline or ""))
        or any(
            STEALTH_PATTERN.search(f"{role.company_name or ''} {role.position or ''}")
            for role in current_roles
        ),
    }


def compare(before: dict | None, after: dict) -> str | None:
    if before is None:
        return "stealth label at baseline" if after["hasStealthLabel"] else None
    if after["hasStealthLabel"] and not before["hasStealthLabel"]:
        return "stealth label added"
    if before["currentEmployers"] and not after["currentEmployers"]:
        return "no current role listed"
    before_set = {normalize(name) for name in before["currentEmployers"]}
    after_set = {normalize(name) for name in after["currentEmployers"]}
    if before_set - after_set and after_set - before_set:
        return "current employer changed"
    return None


watchlist = json.loads(Path("watchlist.json").read_text())
state = json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else {}
review = []

for person in watchlist:
    before = state.get(person["linkedinUrl"])
    after = observe(person["linkedinUrl"])
    if "note" in after:
        review.append(
            {**person, "change": "unknown", "before": before, "after": None, "note": after["note"]}
        )
        continue
    change = compare(before, after)
    state[person["linkedinUrl"]] = after
    if change:
        review.append({**person, "change": change, "before": before, "after": after, "note": ""})

Path("review.json").write_text(json.dumps(review, indent=2))
STATE_FILE.write_text(json.dumps(state, indent=2))
print(f"{len(review)} of {len(watchlist)} people to review")
```

How the rules work:

- **The first successful check is the baseline.** Nobody is listed except people whose profile already shows a stealth label.
- **Later runs compare with the last good observation**, and the new observation is stored only after a successful fetch. A failure is listed as `unknown` and changes nothing, so the next run still compares against the last good data.
- **An empty experience list is a real result**, not an error. It only counts as `no current role listed` when the previous observation had a current employer.
- **Company names are compared loosely** – case, punctuation and spacing are ignored, letters in any script count, and an empty name never matches.
- **Each re-check is one profile view on your account**, at a human pace and within the per-account limit you set ([admin limits](/docs/admin-limits)). The person may see the view unless you browse in [private mode](/guides/linkedin-private-mode).
- **The schedule and the state file are yours** – cron, a CI job, or your agent host's scheduled task.

An illustrative prompt for an agent:

> Re-check the LinkedIn profiles in my watchlist and compare each with the observation stored in watchlist-state.json. List anyone whose profile newly shows Stealth or "building something new", no current role, or a different current employer. Update the file only for profiles you fetched successfully. Change nothing on LinkedIn.

## Run it from code or an AI agent

Linked API runs these searches and reads on your own LinkedIn account and returns JSON; your CRM or database keeps the pipeline.

- **From code:** the REST API and the [Node and Python SDKs](/sdks), as above. Company pages come from `fetchCompany` with decision makers, people from `searchPeople` with a `previousCompanies` filter, and any saved Sales Navigator search from its URL.
- **From the shell:** `linkedin company fetch <url> --dms` and `linkedin person fetch <url> --experience` in the [CLI](/cli/commands).
- **From an agent:** the [MCP server](/mcp/available-tools) exposes `search_companies`, `fetch_company`, `search_people` and `fetch_person`, plus the Sales Navigator versions. The agent-friendly CLI and a ready-made [skill](/skills) work the same way.
- **Sales Navigator searches** need a Sales Navigator subscription on the LinkedIn account and a Plus seat in Linked API.

How an agent gets access to your account, and what else it can run there, is in [the LinkedIn AI agent guide](/guides/linkedin-ai-agent). Every Sales Navigator filter, by plan, is in [LinkedIn advanced search](/guides/linkedin-advanced-search#sales-navigator-search-filters-the-full-list), and the people-by-role pattern is in [mapping a B2B buying committee](/guides/b2b-buying-committee).

## Frequently Asked Questions (FAQ)

#### What is proprietary deal flow?

Deals an investor finds and starts directly with the owner or founder, before or instead of an auction run by a bank or broker. Firms use the term generously: PE investors in a 2015 survey called almost half their closed deals proprietary, and the authors could not verify what that meant.

#### How do private equity firms source deals?

Through their own outreach about as often as through investment banks, according to a 2015 survey of 79 PE investors: about 36% of closed deals were self-generated and 33% came from investment banks, with the rest from executive networks, brokers, management and other PE firms.

#### How do VCs find startups before they raise?

Mostly through networks – colleagues, other investors and portfolio founders – and their own outreach; only about 10% of deals arrive inbound. For founders who have just left a notable employer, a watchlist of their LinkedIn profiles, compared run to run, surfaces stealth labels and departures for review.

#### Is "Stealth Startup" a real company on LinkedIn?

No. It is a community company page created by members, which tens of thousands of people list as their employer while they build in stealth, and it points founders to Harmonic's sign-up form. A profile that lists it tells you someone is starting something, not what or with whom.

#### What tools do PE firms use for deal sourcing?

Usually a deal-sourcing database for the company universe and the numbers – Grata, PitchBook or Harmonic, for example – a relationship CRM such as Affinity for contacts and pipeline, and LinkedIn for finding and reaching the owner. Take verified financials, funding and ownership from the database and your own diligence, not from LinkedIn.

#### Can you automate deal-sourcing outreach on LinkedIn?

You can automate the research – lists, company pages, decision makers, profile re-checks – on your own account. Keep the first message personal: free accounts can add a note to only a few connection requests a month, and owners respond to people, not sequences.

## Build it in, or hand it to an agent

Build the owner list or the watchlist into your own CRM or deal pipeline with the REST API, the [Node and Python SDKs](/sdks) or the shell [CLI](/cli). Or have an AI agent run it out of the box through the [MCP server](/mcp), the agent-friendly CLI or a ready-made [skill](/skills), on your own LinkedIn account. Plans are flat per seat, from $49/mo billed annually – see [pricing](/pricing).

*Facts verified October 30, 2026 – NBER papers, Bain, LinkedIn Help pages and the competitor pages named here checked on that date.*
