---
name: linkedin-profile-reader
description: Read a LinkedIn profile and derive where a person ACTUALLY works right now — current employer, current title, and the company LinkedIn URL that every downstream enrichment and ICP lookup keys on. Use this skill WHENEVER a workflow needs a person's employer, job title, seniority, or company URL, and specifically BEFORE spending any enrichment credit on them, before scoring anyone against an ICP, before adding a lead to an outreach list, and before mapping a creator or prospect to a company. Use it whenever you see "who does X work for", "what's their current role", "is this person still at", "verify this lead", "get the company URL", "check their title", "resolve this person to a company", or a plan that reads an employer out of a headline, a CSV column, or an enrichment provider's `company` field. Use it even when an employer name is already sitting right there and the task sounds trivial ("just grab their company") — a provider returns a confident employer for every row whether or not it is true, contact data decays about 2% a month, and the most dangerous record is the one that is complete, internally consistent, maximally confident, and about the wrong company. Prefer this over reading a headline, trusting an enrichment `company` field, or fuzzy-matching a company name to a domain.
---

# LinkedIn profile reader

Turn a LinkedIn profile into one answer you can defend: **who does this person work
for right now, in what role, and at which company URL** — plus the evidence and the
date the answer was observed.

Everything downstream keys on this. Company enrichment needs a company LinkedIn URL,
not a name. ICP scoring needs the right company's ICP. Outreach needs an employer the
person has not left. Get this wrong and nothing downstream can detect it, because a
wrong-but-coherent record passes every validator you have.

## Onboarding — start here

**What this does.** Takes a LinkedIn profile URL (or a batch of them), reads the
experience section, and returns the current employer, current title, company
LinkedIn URL, plus a `source` and an `as_of` date. It also tells you when the answer
is *ambiguous* or *unresolved* instead of guessing.

**What it does not do, and where to go instead.**

| Not this | Go here |
|---|---|
| Company headcount, revenue, industry, tech stack | Blitz `/enrichment/company`, or Clay on a definitive not-found |
| What a company sells / who it sells to / its ICP | `icp-research` |
| Finding people to read in the first place | `linkedin-comment-scraper`, `lead-generation` |
| Email or phone for a person | Blitz `/enrichment/email`, `/enrichment/phone` |
| Reading a company's website | `firecrawl-build-scrape` |
| Sending anything to anyone | Not this skill. This skill only reads. |

This skill answers *where do they work*. It does not answer *what is that company
like* — that is a firmographics question, and providers answer it well. The split
matters: **providers return firmographics reliably and business context never**, so a
row whose employer came from a provider must carry lower confidence than one read
from the profile.

**Setup.**

```bash
set -a && . ./local.env && set +a
```

Needs `UNPILE_API_KEY`, `UNPILE_DNS`, `UNIPILE_LINKEDIN_ACCOUNT_ID` for single reads;
`APIFY_API_KEY` for bulk. Never hardcode a key — read it from the environment and
fail loudly if unset.

**Verify it works** (one read, free, no credits):

```bash
python scripts/derive_employer.py --self-test
```

**Cost per run.** Unipile profile reads are included in the seat, not metered — the
real limit is the account's safety, so pace at ~4s between calls. Apify
`harvestapi~linkedin-profile` is about **$0.004/profile**; 1,000 profiles ≈ $4.
Blitz and Clay are **not used by this skill** and should not be called from it.

**Invoke it** by asking for someone's current employer, or by running the script on a
list. Read `references/derivation.md` before changing the rule,
`references/providers.md` before switching provider, and
`references/failure-modes.md` before trusting any result — it holds the failures that
actually happened here, each with what it cost.

**The one rule to carry if you read nothing else:** the profile is stale data, the
posts are fresh data, and **when a post contradicts the profile, the post wins.**

---

## Unipile: one account only, passed explicitly

If the workspace has several connected LinkedIn accounts, only the operator's own may
be used. Which one that is belongs in the project's own config (`CLAUDE.md` or an
env var), not in this file — read it there and never guess.

Pass `account_id` on **every** call rather than relying on a default — an omitted id
may resolve to a different account. Being connected to a workspace is not consent:
acting through someone else's personal account misrepresents who is acting and puts
their account at risk. If a task seems to need another account, stop and ask.

Assert it in code, don't just intend it:

```python
if account_id != EXPECTED_ACCOUNT:
    sys.exit(f"account_id is {account_id}, expected {EXPECTED_ACCOUNT}. Stop.")
```

## The derivation rule

Read the experience section and apply, in order:

1. **Current = the end date literally reads "Present".** harvestapi emits
   `endDate: {"text": "Present"}` with no month/year for an ongoing role, and
   `{"month": "Mar", "year": 2022, "text": "Mar 2022"}` for one that ended. Do **not**
   test for a *missing* end date — the key is present either way, so a falsy-check
   silently marks every ended role current.
2. **Cut non-operating roles automatically.** Volunteering, board seats, advisory,
   mentor, ambassador, investor, speaker, trustee, committee, emeritus,
   non-executive. These are real entries on a real profile, but they are not the
   company whose ICP you want. A founder on two nonprofit boards advising three
   startups has six current positions and one employer.
3. **If one operating role remains, that is the answer.**
4. **If several remain, take the first-mentioned.** LinkedIn lists the position a
   person treats as primary first. Do not sort by date — sorting silently overrides
   the person's own ordering, which is the only signal you have about which job they
   consider theirs.
5. **If none remain, return unresolved.** Do not fall back to a board seat. An empty
   answer is recoverable; a wrong one is not, because nothing downstream can catch it.

Match on the **title**, not the company, when deciding what is non-operating:
"Board Director, Melexis" is excluded by its title, while "Director of Engineering"
is kept. Anchor the patterns on word boundaries.

`scripts/derive_employer.py` implements exactly this. Use it rather than
reimplementing — the edge cases below are already encoded and tested.

## Also capture: are they looking, and is the company URL real

Two fields arrive free in the same call and both change how a lead is scored.

**`openToWork` / `hiring`** (top-level booleans). `openToWork` is the "are they
looking" signal: someone job-hunting is a poor buyer — they may not hold the budget
much longer — and an excellent recruiting lead. `hiring` is the inverse and is a
buying-intent signal in its own right. Carry both rather than discovering later that
your best-scoring lead was three weeks from leaving.

**`companyLinkedinUrl` is not always a company.** When the provider cannot resolve
the company page it substitutes a *search* URL and omits `companyId`. Observed live
on one profile:

```
Firecrawl  companyId=104100957  .../company/firecrawl/            <- a key
Mendable   companyId=None       .../search/results/all/?keywords=Mendable  <- not a key
```

A search URL looks valid and matches nothing in enrichment. Treat `companyId` as the
test: no `companyId`, or no `/company/` in the path, means you have a company *name*,
not a company *key*. Carry it as unresolved rather than passing it downstream.

## "Present" is relative to the day you read it

`endDate: {"text": "Present"}` means present *when the page was rendered* — not now.
A cached profile from six months ago still says "Present" and proves nothing today.
This is the concrete reason `as_of` is mandatory rather than nice-to-have.

`duration` ("2 yrs 5 mos") is measured to the same read date, which gives you a free
consistency check on any cached record: `startDate + duration ≈ as_of`. If it does
not hold, you are looking at a stale snapshot and should re-read before trusting it.

## The profile is stale; the posts are fresh

The experience section is what someone remembered to update. Their posts are what
they were doing last month.

People forget to edit their profile but they do announce job changes: *"excited to
share I've joined X"*, *"after four years I'm leaving Y"*. So after deriving an
employer from the profile, check whether the person's recent posts mention that
company at all. Someone who works somewhere mentions it.

**When a post contradicts the profile, the post wins.** Record which source
established employment, so a stale row can be re-checked rather than silently
trusted.

This check is free when you already hold the posts, and it is the one that catches
what the other checks cannot — a *stale* mapping is indistinguishable from a correct
one by every test except recency. Asking "is this a real company they worked at"
answers yes for a stale mapping. Only recency settles it.

## Every derived employer carries a source and a date

Employment is a claim with an expiry, not a fact. Contact data decays at roughly 2% a
month, so a list that was clean a year ago is about a quarter wrong now. A mapping
with no date cannot be re-checked, only re-trusted.

Emit at minimum:

| field | why |
|---|---|
| `company` | the answer |
| `title` | seniority, and it justifies the operating/non-operating call |
| `company_linkedin_url` | the exact key enrichment needs; a name is not a key |
| `basis` | "sole current operating role" / "first-mentioned of N" / "unresolved" |
| `ambiguous` | true when several operating roles competed |
| `source` | `unipile:experience`, `apify:harvestapi`, `post:contradiction`, … |
| `as_of` | YYYY-MM-DD the employment was observed |

Make `as_of` required. A pipeline that lets it default is a pipeline that cannot tell
a fresh answer from a two-year-old one.

## Traps that have actually bitten

Read `references/failure-modes.md` for the full set with costs. The four that recur:

**A missing end date does not prove the role is current.** People leave the end date
off. Treat "no end date" as a *candidate*, not a certification — which is why the
post check exists.

**Between-roles looks identical to employed.** Someone who left in March and starts in
June has a profile that reads exactly like someone still employed. There is no field
that distinguishes them; only their posts do.

**Match company names on word boundaries, and tokenise on whitespace.** Both halves
matter and both have failed here. Substring matching let `hp` match inside *sharp*,
*graphic* and *shipping*, so an HP mapping "confirmed" regardless of truth. And
searching "AFAS Software" as one string found nothing, while the token `afas` matched
immediately.

A check that cannot fail is not evidence. **A false positive on an integrity check is
worse than no check at all**, because it trains everyone to ignore the check.

**A company name is not a company.** Resolving a name to a domain returns a confident
wrong answer roughly one time in five, and string similarity cannot detect it —
"Luxury Digital" matched "DLG (Digital Luxury Group)" at 0.95, giving 121 employees
instead of 2. A 60x error that inverted the label. Errors here do not fail safe: a
bigger wrong company looks like a *better* prospect. Take the company URL from the
profile; do not reconstruct it from a name.

## Derive before you spend

Run this before contact-level enrichment, not after. The order is the whole point:
verification is free or nearly so, enrichment is metered, and enriching someone at a
company they left burns the credit *and* the lead *and* the sending domain.

Emailing someone about a company they left is the most visible possible signal that
outreach is automated and unchecked.

## When you are reading many profiles

Use Apify `harvestapi~linkedin-profile` for bulk rather than pacing thousands of
calls through a personal account. Then:

- **Cache the raw response to disk before parsing.** A parsing bug should cost
  minutes, not dollars — you re-parse the cache instead of re-buying the profiles.
- **Resume by set difference, not a flag.** A profile already cached is done.
- **Verify the field names on a 10-row run first.** If the match key is wrong, every
  record silently fails to join and you pay in full for zero output.
- **An empty response where a non-empty one was expected is not success.** A plausible
  zero is a bug until proven otherwise.

See `references/providers.md` for endpoints, field paths and the cost model.
