# LinkedIn Research Pack

Build per-lead LinkedIn research — profile, recent posts, company, company posts — with **cookieless `scraper.*` tool columns** (managed scraper, no connected account, zero ban risk, spends credits), then synthesize it with AI columns into `icebreaker` / `latest_post_summary` fields that feed `{{column}}` personalization in invite notes and messages. This is how a sequence stops sounding templated without spending a single connected-account read.

## Read First

- [../guides/safe-limits-doctrine.md](../guides/safe-limits-doctrine.md) — the surface decision this recipe embodies: third-party data → cookieless scraper, always.
- [Enriching and Researching](https://oxygen-agent.com/.well-known/skills/oxygen-gtm/enriching-and-researching.md) — tool/AI column mechanics and pilot doctrine.
- [LinkedIn URL Lookup](https://oxygen-agent.com/.well-known/skills/oxygen-gtm/recipes/linkedin-url-lookup.md) — resolve profile URLs first if the table only has names/domains.

## Preconditions

1. A lead table with a `linkedin_url` column per row (and ideally `company_linkedin_url` or a company name).
2. ICP/positioning context for the synthesis prompts: `oxygen knowledge resolve --purpose outbound_copy --require-ready --json`.
3. Credit headroom: `oxygen billing balance --json` — this recipe is the paid step of the LinkedIn motion (costs below).

## Flow

1. **Inspect the catalog entries you'll bind.**
   ```bash
   oxygen tools get scraper.linkedin_profile --json
   oxygen tools get scraper.linkedin_profile_posts --json
   oxygen tools get scraper.linkedin_company --json
   oxygen tools get scraper.linkedin_company_posts --json
   ```

2. **Add the four research tool columns.**
   ```bash
   oxygen columns add <leads_table> --label "LI Profile" --kind tool --data-type jsonb \
     --definition-json '{"toolId":"scraper.linkedin_profile","inputMapping":{"url":{"type":"column","columnKey":"linkedin_url"}}}' --json

   oxygen columns add <leads_table> --label "LI Profile Posts" --kind tool --data-type jsonb \
     --definition-json '{"toolId":"scraper.linkedin_profile_posts","inputMapping":{"profile":{"type":"column","columnKey":"linkedin_url"}}}' --json

   oxygen columns add <leads_table> --label "LI Company" --kind tool --data-type jsonb \
     --definition-json '{"toolId":"scraper.linkedin_company","inputMapping":{"company":{"type":"column","columnKey":"company_linkedin_url"}}}' --json

   oxygen columns add <leads_table> --label "LI Company Posts" --kind tool --data-type jsonb \
     --definition-json '{"toolId":"scraper.linkedin_company_posts","inputMapping":{"company":{"type":"column","columnKey":"company_linkedin_url"},"posted_limit":{"type":"literal","value":"month"}}}' --json
   ```
   No company URL? `scraper.linkedin_company` also takes `search` (company name) — map it from `company_name` instead.

3. **Pilot one row, inspect, then batch under a cap.**
   ```bash
   oxygen columns run <leads_table> li_profile --limit 1 --json
   oxygen cells inspect <leads_table> <row_id> li_profile --json
   # then each column as a bounded background run:
   oxygen columns run <leads_table> li_profile --all --background --approved --max-credits <approved_cap_from_current_preview> --json
   ```
   Run the two `*_posts` columns **only on qualified rows** because they add two paid calls per lead. Gate them with `--run-condition` on your qualification column, or run with `--limit` on a filtered view.

4. **Synthesize with AI columns.** Two fields the sequence templates will consume:
   ```bash
   oxygen columns add <leads_table> --label "Latest Post Summary" --kind ai --data-type text \
     --definition-json '{"prompt":"From these LinkedIn posts (person: {{profile_posts}}; company: {{company_posts}}), summarize in one sentence the most recent, most specific thing worth referencing in outreach. If nothing substantive, output: none.","inputMapping":{"profile_posts":{"type":"column","columnKey":"li_profile_posts"},"company_posts":{"type":"column","columnKey":"li_company_posts"}},"credentialMode":"hosted","modelTier":"standard"}' --json

   oxygen columns add <leads_table> --label "Icebreaker" --kind ai --data-type text \
     --definition-json '{"prompt":"Profile: {{profile}}. Latest-post summary: {{latest_post_summary}}. Our positioning: <one line from context>. Write one specific, non-flattering icebreaker sentence referencing their actual work. No emojis, no exclamation marks.","inputMapping":{"profile":{"type":"column","columnKey":"li_profile"},"latest_post_summary":{"type":"column","columnKey":"latest_post_summary"}},"credentialMode":"hosted","modelTier":"standard"}' --json

   oxygen columns run <leads_table> icebreaker --limit 1 --json   # pilot, read it critically, then batch
   ```

5. **Feed the sequence.** Include `icebreaker` and `latest_post_summary` in each lead's `row_values` when building `leads.json`, then reference them in templates: invite `note_template: "Hi {{first_name}} — {{icebreaker}}"`, message `template: "… {{latest_post_summary}} …"`. Enrollment and caps per [LinkedIn Sequence Outbound](https://oxygen-agent.com/.well-known/skills/oxygen-gtm/recipes/linkedin-sequence-outbound.md).

## Credit Cost Note

Scraper prices are runtime data, not recipe constants. Fetch each selected operation with `oxygen tools get <tool_id> --json`, then dry-run one representative row with the exact input shape. Multiply the returned per-request estimate by the eligible row count, account for every selected posts/resolver column, present the worst-case total, and only then choose `--max-credits`.

Scope posts columns to a shortlist because every selected row adds calls. Always pilot `--limit 1`; inspect the returned estimate and cell output before approving a capped background run.

## Approval & Safety Rules

- Every `scraper.*` run is a paid managed-provider call: pilot first, present the per-row cost x row count before any batch, and run batches `--background --approved --max-credits N`.
- Never route third-party research through a connected account "because it's free" — that's the account-ban vector the doctrine guide exists to prevent. Credits are the price of zero account risk.
- The scraper reads public LinkedIn data through managed infrastructure; it can't read private profiles or your own WVMP/relations (those are connected-account surfaces).

## Stop Conditions

- Empty/thin `*_posts` results → the person/company rarely posts; the AI summary outputs `none` and the icebreaker falls back to profile facts — don't re-run hoping for more.
- Credit burn ahead of estimate (`oxygen billing usage --limit 20 --json`) → stop the background run, re-check which rows the posts columns are running on.
- Bad `linkedin_url` values (lookup errors in cells) → fix identity first via the URL-lookup recipe; don't spend on broken keys.

## Pair With

- [comment-to-dm-funnel.md](comment-to-dm-funnel.md) / [profile-viewers-outreach.md](profile-viewers-outreach.md) → the warm audiences this pack personalizes.
- [LinkedIn Sequence Outbound](https://oxygen-agent.com/.well-known/skills/oxygen-gtm/recipes/linkedin-sequence-outbound.md) → where `{{icebreaker}}` gets sent.
