GTM · Verification
LinkedIn Profile Reader
Where someone actually works right now, established before you spend a credit on them. A provider returns a confident employer for every row whether or not it is true — and when a recent post contradicts the profile, the post wins.
This skill ships 4 files. The references are where the method lives — SKILL.md on its own will point at files you do not have, so take the archive rather than the markdown.
SKILL.mdreferences/failure-modes.mdreferences/providers.mdscripts/derive_employer.py
Prefer just the instructions? Download SKILL.md alone.
Use it in your assistant
Claude Code — drop the file in your skills folder and it loads on the next session. Use ~/.claude/skills for every project, or .claude/skills inside a repo to keep it to that project.
mkdir -p ~/.claude/skills
curl -L https://growsteady.io/skills/linkedin-profile-reader/archive | tar xz -C ~/.claude/skillsClaude apps (web and desktop) — Settings → Capabilities → Skills → add a skill. Extract the archive and upload the whole linkedin-profile-reader folder, references included (zip it if an archive is asked for).
No install— paste the file into a Claude Project's custom instructions with “Copy as prompt”. Same behaviour, scoped to that project. Note that a paste carries the instructions only: this skill's references do not come with it, so use a real install if you want the full method.
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.
set -a && . ./local.env && set +aNeeds 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):
python scripts/derive_employer.py --self-testCost 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:
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:
- 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. - 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.
- If one operating role remains, that is the answer.
- 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.
- 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 keyA 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.
