Give it a list of accounts. It reads every member of each account's sales team on LinkedIn and hands back one intelligence brief per account: sales motion, team shape, positioning. The unit of output is the account, not the contact.
---
name: sales-team-summary
description: Account-level outbound research. Given a list of accounts, produce per-account intelligence by reading the sales team's LinkedIn-public text. The unit of output is the account, not the contact. Use when a user wants to research a list of companies before outbound — understand each company's sales motion, team shape, and positioning before writing copy or making calls.
user-invocable: true
argument-hint: "<google_sheet_url_or_id> [--region uk] [--domain-col domain] [--name-col company_name] [--linkedin-col linkedin] [--countries GB,US]"
---
# /sales-team-summary — Account-Level Sales Team Research
## What this skill is
You hand it a list of accounts. It hands you back, **one row per account**, an intelligence brief built by reading every member of that account's sales team on LinkedIn.
The unit of output is **the account**. The sales team is the evidence, not the deliverable. A company with no sales team on LinkedIn is still a row — the finding "no public sales team" is itself signal for outbound.
This skill stays in one lane: **insights from the sales team via LinkedIn.** No web scraping. No news. No company-level enrichment fallback. If the team is too thin to read, the row says so and the relevant fields are empty.
## When to use this skill
- You have a target account list and want to understand each account's sales motion before writing copy
- You're prepping for a calling session and want a one-screen brief on each company
- You're building an ICP and want to see how a sample of accounts in your hypothesized market actually sell
## When NOT to use this skill
- You need contact-level enrichment (emails, phones) — run an email/phone enrichment tool after this
- You need company-level firmographic data (revenue, headcount, funding) — use a firmographic data provider
- You need a fit verdict ("is this a good prospect?") — this skill enriches, it doesn't filter. Run an audit script after.
## Usage
```
/sales-team-summary https://docs.google.com/spreadsheets/d/SHEET_ID/edit
/sales-team-summary SHEET_ID --region uk --countries GB
/sales-team-summary SHEET_ID --countries GB,IE
/sales-team-summary SHEET_ID --countries=all
```
## Arguments
- `sheet` (required) — Google Sheet URL or ID containing the account list
- `--region` (default `uk`) — used to name output files/dirs and sheet tabs
- `--domain-col` — column with the domain (auto-detects from `domain`, `website`, `company_domain`, `Website`, `Domain`, `Company Domain`, `company_website`)
- `--name-col` — column with the company name (auto-detects from `company_name`, `company`, `Company`, `Company Name`, `name`, `Name`)
- `--linkedin-col` — column with company LinkedIn URL if already known (auto-detects from `linkedin_url`, `linkedin`, `company_linkedin`, `LinkedIn`)
- `--countries` — comma-separated ISO-2 country codes to filter team employees to (e.g. `GB`, `US`, `GB,IE`). If omitted, the skill **MUST** prompt at the start of the run. Pass `--countries=all` to skip the prompt and pull all countries.
## Input Sheet Format
Minimum: a column with either a domain or a company LinkedIn URL. Company name is required for matching downstream output.
| company_name | domain | linkedin (optional) |
|---|---|---|
| Diamond Logistics Ltd | diamondlogistics.co.uk | https://www.linkedin.com/company/diamond-logistics-ltd |
| 3P Logistics | 3p-logistics.co.uk | |
LinkedIn URL is optional — the skill resolves it via a two-pass finder when missing.
---
## Output
Two tabs in the source Google Sheet, plus raw per-account team JSONs on disk.
### Tab 1 — `{Region} Account Research` (THE deliverable)
**Every input account = one row. Always.** Including accounts where no team was found. The status column tells you which.
| Column | What it is |
|---|---|
| `company_name` | Pass-through from input |
| `domain` | Pass-through from input |
| `company_linkedin_url` | Verified LinkedIn URL (resolved by the skill if input didn't have one) |
| `status` | One of: `ok` / `no_linkedin` / `no_sales_team` / `error`. See "Status values" below. |
| `sales_team_size` | Unique people Blitz returned for the account, filtered to Sales & BD function and selected countries, after dedup |
| `team_blurb` | Plain-English composition. Example: `"1 Head of Sales, 2 Sales Managers, 4 SDRs/BDRs"`. Deterministic — not LLM. |
| `team_structure` | 1-2 sentences naming senior people by full name + gesturing at org shape when inferrable from the data. LLM-written. |
| `sales_motions_primary` | Comma-separated list of motion tags the team is built around (typically 1-2). LLM-classified with evidence requirement. |
| `sales_motions_secondary` | Comma-separated list of real-but-not-central motions (often empty) |
| `sales_motions_evidence` | One block listing each motion + the verbatim quote(s) + person name(s) that justify it |
| `recent_hires` | Count + names+titles for team members hired in the trailing **6 months** |
| `sales_tech_stack` | Sales tooling vendors inferred from team members' certifications |
| `team_insights` | Five-section narrative block: POSITIONING_THEME / ICP_CLUES / SERVICE_DIFFERENTIATORS / TEAM_DYNAMICS / OUTBOUND_HOOKS |
The raw `team_data_file` filename is NOT in the output tab. The files are saved to disk (see "On disk" below) but the path is internal plumbing, not for the user-facing tab.
### Tab 2 — `{Region} Contacts` (supporting)
**Every deduped sales team member** found by Blitz `employee_finder` and surviving Step 3 dedup. Sorted by seniority desc, then connections desc, within each account. No management+ filter. No per-account cap. The course student gets to see every person Blitz returned, so they can scan the raw data and form their own view.
If a student is using this skill for a list of accounts, they should treat Tab 1 as the headline deliverable and Tab 2 as a drill-down. **Do not** use Tab 2 to filter the account list — accounts with 0 contact rows in Tab 2 are still legitimate accounts (their team is empty in `no_sales_team` cases). The full story is in Tab 1.
| Column | What it is |
|---|---|
| `first_name`, `last_name`, `title`, `linkedin_url` | Identity |
| `headline` | Their full LinkedIn headline |
| `about` | LinkedIn About section, untruncated up to ~1500 chars |
| `job_description` | What they wrote about THIS specific role at this account — the highest-value field for outbound personalization |
| `tenure_months` | Months in current role |
| `city`, `country_code` | Location |
| `connections` | Connection count (LinkedIn caps display at 500+) |
| `skills_top` | Top 5 self-declared skills |
| `certifications` | Top 5 certs |
| `company_name`, `domain`, `company_linkedin_url` | Account identity |
| `sales_team_size`, `team_blurb`, `team_structure`, `sales_motions_primary`, `sales_motions_secondary`, `recent_hires`, `sales_tech_stack` | Denormalized from Account Research so each contact row stands alone |
### On disk
`{output_root}/{region}-teams/{co_slug}.json` — full deduped team for every account processed. Reread later to re-analyze without re-paying Blitz.
---
## Status values (tight, discrete set)
Exactly four. No variants, no sub-cases.
| Status | What it means | What fields you can expect |
|---|---|---|
| `ok` | LinkedIn URL resolved AND team_size ≥ 1 | All fields filled to the extent the team's public data allows |
| `no_linkedin` | Couldn't resolve the company's LinkedIn URL (Pass 1 + Pass 2 both failed or candidate failed verification) | `company_linkedin_url` empty; all team-derived fields empty |
| `no_sales_team` | LinkedIn URL resolved but `employee_finder` returned 0 people in Sales & BD function (after country filter) | `company_linkedin_url` filled, `sales_team_size = 0`, team-derived fields empty |
| `error` | Pipeline failed for this account (API timeout, malformed data, etc.) | Most fields empty; failure context goes to log |
**The country filter case folds into `no_sales_team`.** If the user filtered to GB only and the team is all US, that's still `no_sales_team` from the skill's perspective. The country filter is documented as part of the run, not in the status column.
**`no_sales_team` is a finding, not a failure.** A small private B2B company with no LinkedIn-active sales staff is real signal — likely founder-led sales, no playbook, possibly a good fit for a "we'll install one for you" pitch. Keep the row. Empty team-derived fields are honest.
---
## Sales motion classification (LLM-driven)
This is the field outbound copywriters care about most: *how does this team actually sell?* It replaces the old brittle regex-based `sales_model` column.
### The 8 motion tags
| Tag | What it indicates | Signals in team text |
|---|---|---|
| `outbound_sdr` | Dedicated outbound prospecting roles | SDR/BDR titles; phrases like "prospecting", "lead generation", "pipeline generation", "booked X meetings" |
| `cold_email` | Email-led outbound | Tooling certs (Outreach, Salesloft, Apollo, Smartlead, Instantly, Lemlist); phrases like "email sequences", "multichannel cadences", "cold email campaigns" |
| `cold_calling` | Phone-led outbound | "Outbound calls", "dialing", "X calls/day", "telesales"; phone tooling (Orum, JustCall, Aircall, Nooks) |
| `field_sales` | In-person/territory sales | "Field sales", "territory", "regional/area sales", "outside sales", "on-site visits", "travel-heavy" in job_description |
| `partner_channel` | Sales via partners, not direct | "Channel sales", "partner manager", "alliances", "reseller", "distributor management", "MSP partners" |
| `account_based` | Concentrated effort on named accounts | "ABM", "named accounts", "strategic accounts", "key accounts", "enterprise accounts", "target account list", "1:few" |
| `inbound` | Selling against inbound demand | "Inbound SDR", "MQL qualification", "demo qualification", "marketing-qualified", "inbound pipeline" |
| `account_management` | Post-sale expansion / relationship-driven selling | AM / Senior AM / Key AM titles are sufficient evidence by themselves — the role IS the motion. Bonus signals if found: "renewals", "expansion", "upsell", "land-and-expand", "existing accounts", "customer growth" |
### Strength tiers
- **`primary`** — the team's main motion(s). What they're built around. Usually 1-2 primaries per account.
- **`secondary`** — a real but not-central second motion. Often empty.
- **Total cap: 3 tags across primary + secondary combined.** A team with more than 3 motion tags becomes scannable noise. If more than 3 motions clear the evidence bar, the LLM picks the 3 with the strongest evidence and drops the rest.
- **No `trace` tier.** If the evidence isn't strong enough for primary or secondary, the motion doesn't appear. A single mention in one person's past-role text is NOT enough — it gets dropped, not tagged. Trace tags are how multi-select gets muddied.
### Evidence rules (these are the rules that stop muddying)
These rules are non-negotiable. The LLM gets them as part of the prompt and any motion that doesn't pass them must be omitted.
1. **Threshold.** A motion may only be tagged if at least one of these is true:
- (a) **2+ team members** show the signal, OR
- (b) **1 team member** shows it in their **current-role** job_description AND the phrase is not hedged, OR
- (c) Team is size 1 and that person's current role clearly states the motion
2. **Required quote.** Every motion in the output must be backed by a verbatim quote from a specific person's current-role text, with the person's name. **No quote → no tag.**
3. **Recency.** Quotes must come from current-role `job_description`, `headline`, or `about_me`. Previous-role experience text is research context, not motion evidence.
4. **Hedge blocklist.** These phrases disqualify a quote: `"also"`, `"sometimes"`, `"in past roles"`, `"previously"`, `"before that"`, `"earlier in my career"`, `"used to"`. If the quote contains any of these surrounding the motion phrase, drop it.
5. **Empty is allowed.** If no motion meets the bar, all three motion columns stay empty. `unknown` is NOT a tag — empty cells are honest. Thin teams legitimately produce empty motion columns.
### Evidence column format
```
outbound_sdr (primary): Danielle Oneil — "I run outbound prospecting into mid-market 3PLs, booking 8-12 discovery meetings per month."
cold_calling (primary): Tom Hartley — headline reads "BDR | 80 dials a day into UK ecom warehousing."
```
One line per motion. Tag, strength, name, quote. The quote is what defends the tag.
### Mental model
The motion columns are **filterable labels for pivoting a list**. They are not narrative. The narrative still lives in `team_insights`. A reader looking at a sortable spreadsheet sees the motions; a reader doing pre-call prep reads the insights block. Different jobs, different fields.
---
## Sales tech stack (rewritten keyword list)
`sales_tech_stack` is now scoped to **sales tooling only** — not generic IT vendors. The old list (Lenovo, Cisco, Palo Alto, CrowdStrike, etc.) was leftover from a cybersec campaign and never made sense here.
Detected from team members' `certifications` list. The vendor keyword list:
**CRM:** Salesforce, HubSpot, Pipedrive, Close, Copper, Attio, Microsoft Dynamics
**Sales engagement:** Outreach, Salesloft, Apollo, Groove, Mixmax, Reply.io
**Cold email:** Smartlead, Instantly, Lemlist, Mailshake, Quickmail, Woodpecker
**Dialer / calling:** Orum, JustCall, Aircall, Nooks, Dialpad, RingDNA, ConnectAndSell
**Data / enrichment:** ZoomInfo, Apollo, Cognism, Lusha, Clearbit, LeadIQ, Clay, AI Ark
**Conversation intel:** Gong, Chorus, Fathom, Avoma, Wingman
**Scheduling:** Calendly, Chili Piper, Cal.com
**Sales-side AI:** 11x, Regie.ai, Lavender, Octave, Twain
**Forecasting / RevOps:** Clari, Gong Forecast, BoostUp, Aviso
Match case-insensitively. Return the deduplicated set joined by `, `. Empty string if no matches.
If a future ICP needs different vendors (e.g. cybersec, IT services), this list is the right place to edit — not the skill structure.
---
## Processing Pipeline
The pipeline is a **single runner** that does everything end-to-end. **No intermediate sheet pushes.** The sheet is only written once, at the very end, after all merges are complete. This is non-negotiable — the May 13 test produced wrong-looking output because intermediate state was pushed and the final merge never re-pushed.
### Step 0: Confirm target countries (MANDATORY prompt)
Before pulling any team data, confirm with the user which countries to filter `employee_finder` to, unless `--countries=...` was passed. Without a country filter, `employee_finder` returns employees globally — for a UK 3PL with overseas affiliates that means Spanish/Mexican reps showing up in the team. This is the #1 cause of "why is there a US person here?" feedback.
**Use `AskUserQuestion`** with a single non-multiselect question:
- Question: `Which countries should we pull contacts from?`
- Header: `Countries`
- Options:
- `United Kingdom (GB)` — `Filter Blitz employee_finder to country_code=["GB"]`
- `United States (US)` — `Filter to country_code=["US"]`
- `UK + US` — `Filter to country_code=["GB","US"]`
- `All countries` — `No country filter — pull global employees`
User can pick **Other** to type a free-text list of ISO-2 codes. Parse on commas, uppercase, strip whitespace.
Save the resolved list as `country_codes: list[str] | None` (None = no filter). Skip this step only if `--countries` was passed. `--countries=all` means `None`.
### Step 1: Resolve and verify company LinkedIn URL
This step has two phases: **find** a candidate URL, then **verify** it's actually the right company. Verification is non-negotiable — Pass 1 and Pass 2 both regularly return wrong-but-plausible matches (acquired-brand artifacts, same-name-different-industry, holding-co-not-operating-co). A wrong URL silently poisons every downstream field.
**Pass 1 — Blitz domain lookup**
- `BlitzClient.domain_to_linkedin(domain)`. Flat-rate, fast.
- Hits ~14% on small/private firms (observed on UK 3PLs).
**Pass 2 — Serper + Blitz** (always run — even if Pass 1 returned something, Pass 1 may be wrong)
- Primary queries:
- `site:linkedin.com/company "{name}" {region_qualifier}` (e.g. ` UK`)
- `site:linkedin.com/company "{name}"`
- **Sparse-name fallback:** if the input name is short or generic (fewer than 2 non-blocklist tokens — e.g. just "Cannon"), also query using a name derived from the domain. `cannonpacking.co.uk` → `cannon packing`. This catches inputs where the company name was shortened in the source data.
- Take the **top 5 organic results per query** whose slugs share a non-generic name token with either the input name OR the domain-derived name. Use a generic-word blocklist tuned per ICP — e.g. for 3PLs: `3pl logistics fulfilment fulfillment limited ltd uk the and co company group services service solutions international global centre center warehouse shipping transport courier`.
- **Capping candidates at 2 is wrong.** A real failure: input "Cannon" matched `cannon-technologies-ltd` (manufacturer) and `spurling-cannon` (accountants) as the top 2, but the correct `cannon-packing-and-logistics` page sat at Serper position #3 and was missed. Cap at 5 or higher and let the LLM verifier sort them.
- Combined with Pass 1, expect ~60% candidate coverage on small private firms.
**Verification gate (LLM-driven, every candidate)**
The verification is built around one principle: **read the evidence, don't score it.** Deterministic scoring can't see meaning. A candidate scored 4 from name-overlap + size signals is indistinguishable from the right company — until you read the `industry` field and see "Manufacturing" when you wanted a 3PL. The LLM has to make the call.
For every candidate URL (Pass 1 or Pass 2):
**Sub-step 1 — Cheap pre-filter.** Drop the obviously broken before paying for an LLM call:
- `company.name` is empty → reject (malformed response)
- `company.employees_on_linkedin == 0` AND `company.size` is empty → reject (dead page)
- Input domain has a country TLD (`.co.uk`, `.de`, `.fr`, etc.) AND `company.domain` has a different country TLD AND domain base prefixes don't match → reject (wrong-country namesake)
These take 0 ms after the `company_enrichment` call. ~5-10% of candidates die here.
**Sub-step 2 — LLM verification.** Every surviving candidate gets read. Pull these fields from the `company_enrichment` response (top-level under `company`):
- `name`, `domain`, `website`
- `industry`
- `size`, `employees_on_linkedin`
- `hq.city`, `hq.region`, `hq.country`
- `about` (truncate to ~800 chars)
- `specialties`
Send to the LLM with this prompt:
```
You are verifying whether a LinkedIn company page is the same company as the one we're
trying to research. Wrong matches poison every downstream insight, so be strict.
THE COMPANY WE'RE LOOKING FOR:
name: {input_name}
domain: {input_domain}
region: {country_codes or "not specified"}
THE LINKEDIN PAGE WE FOUND:
name: {company.name}
domain: {company.domain}
website: {company.website}
industry: {company.industry}
size: {company.size}
employees: {company.employees_on_linkedin}
hq: {company.hq.city}, {company.hq.region}, {company.hq.country}
about: {company.about[:800]}
specialties: {company.specialties}
Compare the input against the LinkedIn data. Look for:
- Domain match (strongest single signal — same domain almost always means same company)
- Industry/sector alignment with the input company's likely business
- Whether 'about' / 'specialties' describe the same kind of operation
- HQ region matches expected
- Red flags: acquired-and-rebranded artifacts, dormant pages, name-only matches in
different industries, holding-co-not-operating-co mismatches
Answer in EXACTLY this format:
VERDICT: YES|NO|UNSURE
REASON: <one sentence>
YES = same company, high confidence
NO = different company OR wrong-industry namesake OR acquired-rebranded artifact OR shell page
UNSURE = thin data, genuinely can't tell
```
**Apply the verdict:**
- `YES` → accept this URL
- `NO` or `UNSURE` → reject; try the next candidate
**Try up to 5 Pass-2 candidates per account plus Pass 1's single result.** If all fail verification, `status = no_linkedin`. The 5-cap was chosen because a real failure case ("Cannon" → correct page at position #3 missed when capped at 2) showed that generic-name inputs need more candidates.
**Log every verdict to the run log** — input company, candidate URL, the LinkedIn page's name/industry/about-summary, verdict, reason. A student should be able to read the log and understand why each account was accepted or rejected.
**Cost:** LLM call per candidate × up to 3 candidates per account. On 10 accounts that's ~30 LLM calls. Trivial cost (cents) against the cost of wrong downstream data.
**Implementation note (Python runner):** the Python runner cannot directly invoke the Agent tool. It produces a `candidates.json` file with every candidate's full company_enrichment payload, an LLM verifier (subagent or direct Anthropic SDK call) reads it and writes back verdicts.json, and the runner resumes from the verdicts. Two-phase is fine — it keeps the verifier auditable and lets you re-run verification without re-pulling Blitz data.
### Step 2: Pull the sales team
- `BlitzClient.employee_finder(company_linkedin_url, job_function=["Sales & Business Development"], country_code=country_codes, max_results=50)`
- Omit the `country_code` kwarg entirely if `country_codes` is `None` (all countries)
- Paginate up to 5 pages (250 people max — covers any normal team)
- If 0 results: `status = no_sales_team`. Keep the row. All team-derived fields empty.
### Step 3: Deduplicate
Two profiles are duplicates if 2+ of these signals match:
1. Same first name (case-insensitive)
2. Same city + state
3. Same LinkedIn slug base (trailing ID after the last hyphen)
4. Same `about_me` text (non-empty)
5. Same certifications set (non-empty)
Keep the profile with the highest freshness score: `seniority_score(title) * 10 + start_date_as_int + completeness_bonuses + min(connections/500, 1)`.
### Step 4: Capture rich per-person fields
For every deduped person, save **every useful field** Blitz returns:
```python
{
"name": p["full_name"],
"first_name": p.get("first_name"),
"last_name": p.get("last_name"),
"headline": p.get("headline") or "",
"about_me": p.get("about_me") or "",
"title": current_title,
"job_description": current_role_job_description, # from experiences[where company matches]
"tier": classify_tier(title),
"seniority": seniority_score(title),
"start_date": current_role_start_date,
"tenure_months": _tenure_months(current_role_start_date),
"linkedin": p.get("linkedin_url"),
"connections": p.get("connections_count") or 0,
"city": (p.get("location") or {}).get("city") or "",
"country_code": (p.get("location") or {}).get("country_code") or "",
"profile_picture_url": p.get("profile_picture_url") or "",
"skills": [s for s in (p.get("skills") or []) if s][:10],
"certifications": [c.get("name", "") for c in (p.get("certifications") or []) if c.get("name")],
"education": [{"school": e.get("school_name"), "degree": e.get("degree"), "field": e.get("field_of_study")} for e in (p.get("education") or [])[:3]],
}
```
Save the full deduped team list to `{output_root}/{region}-teams/{co_slug}.json`. This is the input for Step 7.
### Step 5: Deterministic derived fields
These run in Python on the deduped team. No LLM.
- **`team_blurb`** — group team members into role-synonym buckets (regex match in order, first match wins), pluralize, sort by seniority, comma-join. Buckets: Head of Sales/VP → Sales Director → Sales Manager → BD Manager → Account Managers → Field reps → SDRs/BDRs → Sales reps → Other commercial. Example output: `"1 Head of Sales, 2 Sales Managers, 4 SDRs/BDRs"`.
- **`recent_hires`** — filter team to those whose **current-role start_date is within the trailing 6 months**. Format as `"N: Name1 (Title1); Name2 (Title2); ..."`. Cap at 5 names. **Date handling rules:**
- `start_date` is in `YYYY-MM-DD` format → parse normally and compare to today − 180 days.
- `start_date` is year-only (`YYYY`) → if the year is the current year, accept the person and treat as 6-months-old; if the year is a prior year, ignore.
- `start_date` is empty → ignore.
- Tighter cutoff than the old 12-month window. Smaller, sharper signal — recent hires inside 6 months are a stronger buying-window indicator.
- **`sales_tech_stack`** — walk every team member's certifications, case-insensitive match against the sales tooling vendor list above, return the deduped set joined by `, `.
### Step 6: Write Contacts for Tab 2
- **Write every deduped sales team member.** No management+ filter. No per-account cap.
- Already sorted by `seniority` desc, then `connections` desc (from Step 4).
- An account with 0 contact rows means `sales_team_size == 0` (no_sales_team or no_linkedin) — not "data missing."
A management+ tier filter was used in earlier versions but produced misleadingly thin Contacts tabs (e.g. 19-person dataset reduced to 2 rows because Account Managers and Sales Representatives fell outside Executive/Sales Management tiers). The full team is more useful as a teaching artifact and reflects what Blitz actually returned.
### Step 7: LLM synthesis pass (team_structure, sales_motions, team_insights)
This is the one LLM-heavy step. Each batch produces five output fields per company in a single pass. The team JSONs from Step 4 are the input.
**Batching:** group ~20 accounts per batch. The orchestrator dispatches batches **in parallel** (Python `concurrent.futures.ThreadPoolExecutor`, ~5 workers — enough to amortize latency without flooding the API) but **blocks until every batch has written its insights CSV before moving to Step 8**. Per-batch CSVs land at `{region}-insights-batch-NNN.csv`. The orchestrator does not proceed to consolidate until all `len(manifests)` CSVs exist on disk.
Two valid dispatch paths, both supported by `orchestrator.py`:
1. **Direct Anthropic SDK calls** (default in `orchestrator.py`) — one `client.messages.create` per batch, threaded. Faster, simpler, no extra tooling required.
2. **Claude Code `Agent` tool** — if the orchestrator is itself invoked from inside a Claude Code conversation, foreground `Agent` calls per batch also work. Do NOT use `run_in_background: true` — Step 8 needs the CSVs on disk and background agents don't block.
**Prompt template:**
```
You are extracting sales-team intelligence from raw LinkedIn data for outbound prep at a B2B agency.
Input: a batch manifest JSON listing N company team-data files. Each file contains the company's
full deduped team with headline / about_me / job_description / tenure / location / skills / certs.
For EACH company, produce FIVE fields:
1. `team_structure` — 1-2 sentences naming the senior person(s) by full name and gesturing at the
org shape if inferable. Examples of the style:
- "Mike Larry is Head of Sales; Dave Smith and Bob Jones each manage ~3 SDRs underneath."
- "Sales Director Jane Doe leads a 5-person UK team (mix of AMs and BDMs); no inside-sales layer visible."
- "Two-person bench: BDM Oliver Hilton and AM Kerry Holmes — no clear reporting line visible."
Rules:
- Use REAL full names. Don't invent reporting relationships unless someone literally writes
"reports to X" or "manages a team of Y", OR title hierarchy makes it obvious.
- If structure is genuinely unclear, name the most senior person(s) only.
- Under 2 sentences.
2. `sales_motions_primary` — comma-separated motion tags from this fixed list:
outbound_sdr, cold_email, cold_calling, field_sales, partner_channel, account_based, inbound,
account_management
ONLY include motions that meet ALL of these rules:
(a) Threshold — 2+ team members show the signal, OR 1 team member shows it in their
CURRENT-ROLE job_description and the phrase is not hedged, OR team is size 1 and that
person's current role clearly states it.
(b) Required quote — every motion you tag must have a verbatim supporting quote from a
specific named person's current-role text (headline / about_me / current-role
job_description). NO QUOTE → NO TAG.
(c) Recency — only quote from current-role text. Previous-role experience text is research
context, not motion evidence.
(d) Hedge blocklist — these phrases disqualify a quote: "also", "sometimes", "in past roles",
"previously", "before that", "earlier in my career", "used to". If the quote contains any
of these around the motion phrase, drop it.
Empty is allowed. If no motion passes the bar, leave this field empty. Don't write "unknown" or
"none" — leave it blank.
3. `sales_motions_secondary` — comma-separated motion tags that are real but NOT central to how
the team is built. Same evidence rules as primary. Often empty. Don't pad.
TOTAL CAP: at most 3 tags across primary + secondary combined. If more than 3 motions pass
the evidence bar, keep the 3 with the strongest evidence (most quotes, most people backing
them, clearest current-role text) and drop the rest. Primary should typically be 1-2 motions
(the ones the team is built around); secondary fills the rest of the 3-tag budget.
4. `sales_motions_evidence` — one line per tagged motion, listing the motion, its strength, the
person's name, and the verbatim quote. Format:
outbound_sdr (primary): Danielle Oneil — "I run outbound prospecting into mid-market 3PLs."
cold_calling (primary): Tom Hartley — headline reads "BDR | 80 dials a day into UK ecom."
If no motions tagged, leave this field empty.
5. `team_insights` — the five-section narrative block (one cell of text, section labels in CAPS):
POSITIONING_THEME: 1-2 sentences on how the team collectively describes what they sell. Quote
phrases used by 2+ people verbatim.
ICP_CLUES: Who they say they help. Quote real phrases. Empty if no signal.
SERVICE_DIFFERENTIATORS: Repeated services/angles. Quote real phrases. Empty if no signal.
TEAM_DYNAMICS: Tenure shape, geographic spread, seniority skew. One short sentence.
OUTBOUND_HOOKS: Three specific opening lines an SDR could use, drawn from real data points.
Rules:
- Quote real phrases, don't paraphrase.
- 1-2 people with empty text: POSITIONING_THEME = "thin data — only N person(s), limited
public text"; leave other sections empty.
- Don't invent. Empty over padding.
- Each section under 3 lines.
- DO NOT call external LLM APIs (OpenRouter, Gemini, OpenAI). You ARE the LLM — read and
write directly.
Output: a CSV at {path} with columns:
company_name, team_structure, sales_motions_primary, sales_motions_secondary,
sales_motions_evidence, team_insights
Use Python's csv module with csv.QUOTE_ALL — the evidence and insights fields contain commas and
newlines.
Report counts processed/thin/no-motions. Under 100 words.
```
### Step 8: Consolidate + merge + push (single pass, no intermediate sheet writes)
After all subagents finish:
1. Consolidate per-batch CSVs into one `{region}-insights.csv` with the 5 LLM-produced columns plus `company_name`.
2. Merge the LLM columns into the Step 5 account_research CSV by lowercase-stripped `company_name`. Write `{region}-account-research-final.csv`.
3. Denormalize the merged `team_structure`, `sales_motions_primary`, `sales_motions_secondary` into the contacts CSV (so each contact row stands alone).
4. **Only now**, push both CSVs to the Google Sheet (Account Research + Contacts tabs). Bold + freeze header row.
**This is the only sheet push in the entire pipeline.** Do not push intermediate state. If a student's run crashes between Step 5 and Step 8, no sheet was touched — they can resume cleanly.
---
## Important Rules
1. **One sheet push, at the very end.** This is the #1 source of confusion in earlier runs. No intermediate writes.
2. **Every input account = one output row.** No filtering. `no_sales_team` and `no_linkedin` rows belong in the output.
3. **Empty over invented.** If a field doesn't have evidence, leave it empty. The whole skill's credibility depends on the user being able to trust that filled = real.
4. **Stay in the lane.** This skill reads LinkedIn-public sales team data and writes account intelligence. No company website scraping, no news APIs, no firmographic enrichment. Those belong in a separate deep-research skill.
5. **Status values are tight: `ok` / `no_linkedin` / `no_sales_team` / `error`.** No variants. No `no_linkedin_verified`. No `no_sales_team_in_gb`. The country filter is a parameter, not a status.
6. **Save team JSON to disk after Step 4.** Reread later for re-analysis without re-paying Blitz.
7. **Tab 2 is the full deduped sales team.** No management+ filter, no per-account cap. A student should see every person Blitz returned for the account.
8. **Stream rows to CSVs as they're processed.** Crash protection.
9. **Rate limiting.** Blitz auto-throttles. Observed: ~1.5 cos/sec at concurrency 6 in the default client.
---
## Gotchas / Learnings
These come from real runs (UK 3PL list, May 2026). Worth knowing before you run.
1. **`no_sales_team` is not bad-fit.** Small/private B2B firms often have no LinkedIn-active sales staff because the founder/MD does sales. Keep the row. The `team_insights` will be empty — that's honest. Don't let the skill double as a fit filter.
2. **Subagent insight CSVs need `csv.QUOTE_ALL`.** The evidence and insights blocks contain commas and newlines. Without QUOTE_ALL the consolidation step misparses rows. Bake this into the prompt.
3. **Subagent prompt MUST say "do NOT call external LLM APIs".** One subagent silently shelled out to Gemini Flash via OpenRouter in an earlier run, producing slightly different output structure. Subagents ARE Claude — they should read + write directly.
4. **Blitz `company_enrichment` returns `company.domain` top-level**, not nested under `info`. A verifier that reads `result["info"]["company"]["domain"]` will silently get empty strings and reject every candidate. Use `result["company"]["domain"]`.
5. **Serper generic-name blocklist matters.** Without filtering generic tokens for the ICP (`logistics`, `3pl`, `ltd`, etc.), pure-generic company names like "3PL" match anything containing those words. Drop these names from Pass 2 — they need manual handling.
6. **Apify Trustpilot exports** (if used for related work) can contain multiple merchants per file under different `businessUnitId` values. Not directly relevant to this skill but flagged because related campaigns hit it.
7. **Blitz tags people by their self-reported function, not the company's actual sales function.** A "Sales Operations Analyst" gets tagged Sales & BD. A "Customer Success Director" who used to be in sales might too. `sales_team_size` is "people LinkedIn thinks are sales-shaped here," not "people with a quota."
8. **Subagent CSVs must include `company_name` as the join key.** It's tempting to omit it from the LLM's writing task to save tokens — don't. Step 8's merge breaks without it.
9. **Pass 1 and Pass 2 both return wrong-but-plausible LinkedIn URLs surprisingly often.** Real failure modes seen: acquired-brand artifacts ("Cannon Packing" → `formerly-cannon-hygiene-uk`, a different hygiene-services company); same-name-different-industry ("Cannon Packing" → `cannon-technologies-ltd`, a manufacturer); holding-co-not-operating-co; wrong-country same-name. The verification gate in Step 1 is what catches these. **Deterministic scoring is not enough** — a wrong "Cannon Technologies" can score 4 from name-overlap + size + linkedin-presence with no domain match. Always read the meaning (industry, about, specialties), not just signals. The LLM verifier is non-optional.
10. **LinkedIn `start_date` precision varies wildly.** Many users omit the month, leaving year-only. Some omit start dates entirely. The 6-month recent-hires filter accepts year-only dates only if the year is the current year, treated as ~6 months old. This is a deliberate trade-off — under-counting hires is honest; inflating them by treating "joined sometime in 2023" as recent would be misleading.
---
## Layered audit (optional, OUTSIDE this skill)
If the caller wants a binary fit verdict on top of the enrichment, run a separate audit script that produces KEEP / DROP / REVIEW per account. The audit logic does NOT belong inside this skill — it's per-campaign and ICP-specific. See `build_uk_new_audit.py` in the reference scripts section as a template.
The audit consumes this skill's output and an ICP-specific bad-fit keyword list. Don't use `no_sales_team` as a DROP signal — it's an enrichment hole, not a fit signal.
---
## Reference implementations
The reference implementation is a set of scripts you keep alongside the skill:
- **`sales_team_research.py`** — single runner with three CLI subcommands matching the locked schema:
- `phase-a <input.csv> <output_dir> <region> [country_codes]` — Steps 1a+1b (Blitz + Serper candidate resolution), emits `{region}-candidates.json`
- `phase-b <output_dir> <region> [country_codes]` — Steps 2-6 (employee_finder, dedup, rich capture, deterministic fields, contacts CSV). Requires `{region}-verdicts.json` from the verifier.
- `split <output_dir> <region> [batch_size]` — Step 7a: split team JSONs into `{region}-manifests/batch-NNN.json` for synthesis subagents
- `consolidate <output_dir> <region>` — Step 7c: merge per-batch insight CSVs into `{region}-insights.csv`
- `push <output_dir> <region> <spreadsheet_id>` — Step 8: merge insights into accounts, denorm into contacts, push BOTH tabs in a single pass
- **`sales_team_verify.py`** — Step 1c: LLM verification gate. Reads candidates.json, calls Claude per surviving candidate, writes verdicts.json. Uses `claude-haiku-4-5-20251001` for cost; switch in code if you want stricter judgment.
### End-to-end orchestration
**Recommended: one command via `sales_team_orchestrator.py`.** Runs all 8 steps including LLM verification + parallel synthesis. Handles sheet downloads via gwcli when input is a URL/ID.
```
# Input is a sheet URL — auto-downloads via gwcli, pushes results back to the same sheet
python sales_team_orchestrator.py "https://docs.google.com/spreadsheets/d/SHEET_ID/edit" \
--output-dir ./run-uk --region uk --countries GB
# Input is a local CSV — local CSVs only, no sheet push
python sales_team_orchestrator.py input.csv --output-dir ./run-uk --region uk --countries GB,US
# Push results into a *different* sheet from the one the input came from
python sales_team_orchestrator.py input.csv --sheet-id <other_sheet_id> --countries all
```
Flags:
- `--countries CSV` — ISO-2 codes (e.g. `GB,US`) or `all`. If omitted, the orchestrator prompts interactively.
- `--batch-size N` (default 20) — companies per synthesis batch.
- `--workers N` (default 5) — parallel Anthropic SDK calls during synthesis.
- `--synth-model NAME` (default `claude-sonnet-4-6`) — model for synthesis. The verifier uses Haiku 4.5 separately for cost.
- `--skip-verify` — auto-accept first candidate per account. **Debug only.** Wrong-match suppression is what makes this skill credible; using `--skip-verify` on a real list will produce poisoned downstream rows.
**Manual mode (for debugging individual phases):** the 5 CLI subcommands on `sales_team_research.py` still work — see "Reference implementations" above. They write the same intermediate files, so you can re-enter the pipeline at any phase boundary.