USE_CASE_ID: crm_contact_change_detection NAME: CRM contact change detection (still at company + title change) CATEGORY: CRM hygiene The user has a CRM-sourced contact list and wants to detect, by re-scraping each contact's LinkedIn profile, whether the person is still at the company on record and whether their title has changed. Per-contact output is a structured verdict downstream consumers can route on: - Person moved on → retarget the account, archive the contact, optionally send a "congrats on the new role" sequence. - Still at the company but title changed → promotion or lateral move; re-engage with refreshed context. - Couldn't scrape → bucket for retry or manual review. This is the *contact + company-on-record* variant of LinkedIn-based change monitoring. A separate use case (planned) covers the *contact-only* variant — given just a LinkedIn URL with no company-on-record, detect recent role change or promotion from the scraped experiences array directly. For routing, file structure, and cross-cutting principles, see https://floqer.com/docs/use-case-catalog.txt. This file does not repeat them. INDEX: 1. When to use / when not to use 2. Inputs and pre-flight clarifications 3. Outputs 4. Workflow design 5. Implementation 6. Best practices 7. Common variations 8. Failure modes and mitigations 9. Related use cases ================================================================================ 1. WHEN TO USE / WHEN NOT TO USE ================================================================================ USE WHEN: - The user has a CRM-sourced contact list with (LinkedIn URL, Company Name) per row at minimum, optionally Known Job Title and Domain. - They want to know, on a recurring basis, which contacts have moved on from the company on record. - They optionally want a title-change signal for contacts who are still at the company (typical interpretation: promotion or lateral → re-engage with refreshed context). - New rows arrive either from a manual CRM export or a webhook; `auto_run: true` fires the chain per arrival. DO NOT USE WHEN: - The input is a LinkedIn URL with no company-on-record — see the planned "contact-only role-change detection" use case. This use case requires a Company Name to compare against; without it the rule check has nothing to anchor on. - The trigger is per-event (a hiring signal, social activity) and the action is per-company rather than per-contact — see https://floqer.com/docs/use-case-detail/hiring_signal_outbound.txt. - The user wants to detect *future* moves (people about to leave) rather than past moves. LinkedIn surfaces past moves; predicting future moves is a different problem. ================================================================================ 2. INPUTS AND PRE-FLIGHT CLARIFICATIONS ================================================================================ INPUTS (per row, from the CRM export / sync) Required: LinkedIn URL — flagship URL (linkedin.com/in/). Sales Nav opaque URLs (linkedin.com/in/ACoAA...) won't work — the scrape providers reject them. Normalize upstream if you have any. Company Name — company on record in the CRM. The rule check compares this against the scrape's current company. Optional: Domain — company domain on record. Not directly consumed by this chain but useful to carry through for downstream enrichment / audit. Known Job Title — title on record. When present, enables the title-change check. When empty, the title check returns "skipped" and the message is an empty string. PRE-FLIGHT CLARIFICATIONS Ask these before configuring the chain: - **List source.** Is this a one-time CRM export, a recurring sync (e.g. weekly upload), or a webhook-pushed feed of newly-added contacts? Drives whether to bulk-load on Day 1 vs rely on `auto_run` for steady-state. - **Re-scrape cadence.** LinkedIn data changes slowly; weekly is typical, daily is overkill for most CRMs. The scrape is the only paid step (0.2 credits / row) and rate-limits matter at scale. - **Output destination for `Changed Company = yes`.** Common options: push to a "Movers" sub-sheet for rep review, Slack alert per change, CRM update (flag contact / archive old role), trigger a "congrats" sequence on the new role. - **Output destination for "promoted internally" signal** (`Changed Company = no AND Title Changed = yes`). Usually different from a move-on action. Confirm if the user wants this surfaced separately. - **Whether to back-fill or skip `unknown` / `scrape_failed` rows.** These need human triage; decide whether to surface them immediately or only after an automated retry. ================================================================================ 3. OUTPUTS ================================================================================ Per row, three verdict columns drive downstream consumers: Changed Company (string, primary verdict): "yes" — person has moved on from the company on record. "no" — still at the company on record (or a clearly recognised parent/subsidiary). "unknown" — scrape returned partial data; LLM couldn't decide. Bucket for human review. "scrape_failed" — LinkedIn scrape failed entirely (dead URL, locked profile, 404, rate-limited). Bucket for retry or manual handling. Title Changed? (string, gated internally on Known Job Title presence): "yes" — title differs from the CRM record (likely promotion or lateral; combine with `Changed Company = no` for the "promoted internally" signal). "no" — title matches the record (still in the same role). "unknown" — scrape returned no title (rare; usually paired with `scrape_failed`). "skipped" — Known Job Title input was empty for this row. Title Change Message (string, human-readable for Slack / digests): "Title changed from to " when Title Changed = yes. "Unknown" when scrape couldn't read title. "" when no title change or skipped (so Slack templates can drop the line silently). Plus the upstream `enrich_person_linkedin_profile` cell carries the full scraped payload — `current_company_name`, `person_current_job_title`, `experiences` array — for any custom downstream JS the caller wants to write. ================================================================================ 4. WORKFLOW DESIGN ================================================================================ Single main sheet. Linear chain. No sub-floqs, no fan-out. One row per contact, six actions produce all verdicts in one pass. Key design decisions worth understanding: - **Polarity flip at the consolidator.** Internal cells (rule check + LLM) ask "Still at company?" with `yes` = still there. The user-facing answer column reverses to "Changed Company" with `yes` = moved on, which reads naturally for the movers signal. The upstream cells keep "still-at" polarity because it's easier for the LLM to verify "are they still there?" than "did they change?". Flipping happens once at the consolidator. - **Rule check first, LLM as fallback.** The rule check is free and catches the easy cases (verbatim match after normalization, "Acme" vs "Acme Inc" via bidirectional includes). The LLM (gpt-4o-mini ≈ $0.0001/row) is the safety net for rebrands, parent/subsidiary, and surface-form variants the normalizer misses. LLM `run_if` skips when the rule returned `yes` or `scrape_failed` — no point asking it about clear matches or empty data. - **`scrape_failed` is first-class, not lumped with `unknown`.** Dead URLs, locked profiles, 404s, and rate limits all surface as `scrape_failed` so the retry / manual-review path can branch separately from the "checked but uncertain" bucket. - **Title check is internally gated, not via `run_if`.** The action always runs and returns `"skipped"` when Known Job Title is empty. Cleaner than `run_if` gating because the cell always has a deterministic value downstream consumers can read. `run_if`-gated cells produce no output when the gate fails, which renders as `?` in row listings. ================================================================================ 5. IMPLEMENTATION ================================================================================ QUICK REFERENCE — action chain (specific names, types, and refs, as built on the reference workflow): MAIN SHEET (auto_run: true) inputs: LinkedIn URL (url) ref: {{input.linkedin_url}} flagship form, required Company Name (string) ref: {{input.company_name}} company on CRM record, required Domain (string) ref: {{input.domain}} optional, carried for downstream Known Job Title (string) ref: {{input.known_job_title}} optional; gates the title-change message chain: enrich_person_linkedin_profile "Scrape LinkedIn" inputs: linkedin_url = {{input.linkedin_url}} continue_workflow_if_action_fails: true format_data_using_js_expression "Still at Company? (rule check)" outputs: yes / no / unknown / scrape_failed llm_models "Still at Company? (LLM fallback)" model: gpt-4o-mini output_format: { verdict, reasoning } run_if: rule check is not in (yes, scrape_failed) continue_workflow_if_run_condition_not_met: true format_data_using_js_expression "Changed Company" outputs: yes / no / unknown / scrape_failed (consolidator — flips polarity vs the upstream "still-at" verdict) format_data_using_js_expression "Title Changed? (rule check)" outputs: yes / no / unknown / skipped (internally gates on Known Job Title presence) format_data_using_js_expression "Title Change Message" outputs: human-readable string Detailed stages below. STAGE 1 — Scrape LinkedIn Action: enrich_person_linkedin_profile Config: { "inputs": { "linkedin_url": "{{input.linkedin_url}}" }, "continue_workflow_if_action_fails": true } The continue-on-fail flag is essential. Dead URLs / locked profiles / rate-limited scrapes will mark this cell `failed` but the chain continues; the rule check downstream detects the missing payload and emits `scrape_failed` on the verdict column. Without this flag the whole row halts at the scrape and the user has to manually filter the failed rows. STAGE 2 — Still at Company? (rule check, JS) Action: format_data_using_js_expression (() => { const normalize = (str) => { if (!str) return ""; return String(str).toLowerCase() .replace(/[‘’]/g, "'") .replace(/[“”]/g, '"') .replace(/[–—]/g, "-") .normalize("NFD").replace(/[̀-ͯ]/g, "") .replace(/[.,&]/g, "") .replace(/\s+/g, " ") .trim(); }; const onRecord = normalize("{{input.company_name}}"); const current = normalize("{{.current_company_name}}"); // Scrape-failure detection: no current company AND no experiences. let experiences = []; try { experiences = JSON.parse(`{{.experiences}}` || "[]"); if (!Array.isArray(experiences)) experiences = []; } catch (e) {} const hasScrapeData = !!current || experiences.length > 0; if (!onRecord) return "unknown"; // CRM input missing — can't compare if (!hasScrapeData) return "scrape_failed"; // dead URL / 404 / locked profile / rate-limited if (!current) return "unknown"; // partial scrape — LLM can read experiences if (onRecord === current) return "yes"; if (onRecord.includes(current) || current.includes(onRecord)) return "yes"; return "no"; })() Normalization notes (lifted from the Partnerships flow's "End date pass 1" — proven on real CRM data): - Curly / smart quotes → straight. Sources emit "John's" with `'` (U+2019) vs `'` (U+0027) inconsistently. - En / em dashes → hyphen. - NFD + strip combining marks. Folds accented chars to base form. - Strips `.`, `,`, `&`. - Does NOT touch "the", "co", legal suffixes. Bidirectional includes handles "Acme" vs "Acme Inc" naturally, and leaving "the" intact prevents "The Brick" from collapsing to "brick" and false-matching "Brick Industries". Variable-reference syntax: string-typed fields (`company_name`, `current_company_name`) are double-quote-wrapped — the substitution "eats" the quotes and leaves a clean JS string. The `experiences` array field is backtick-wrapped + `JSON.parse`d because it's JSON-typed. See https://floqer.com/docs/action-detail/format_data_using_js_expression.txt §1 INPUTS (Variable substitution behavior) for the side-by-side example and the Trap 2 gotcha on why the choice matters. STAGE 3 — Still at Company? (LLM fallback) Action: llm_models The LLM only fires when the rule check couldn't conclude or returned a clean negative — i.e. NOT in ("yes", "scrape_failed"). It verifies the rule check's "no" results (catches rebrands, parent/subsidiary cases, and surface-form variants) and salvages partial scrapes (`unknown` from rule check, where the `experiences` array still has data). Config: { "inputs": { "model": "gpt-4o-mini", "prompt": "You're verifying whether a person is still working at a specific company.\n\nCompany on record (from the user's CRM): \"{{input.company_name}}\"\nCurrent company on their LinkedIn (scraped): \"{{.current_company_name}}\"\nTheir recent experiences (most-recent-first): {{.experiences}}\n\nDecide whether the person is still at the company on record.\n\nReturn one of three verdicts:\n - \"yes\": clearly still at that company (different spelling, rebrand, parent/subsidiary that's clearly the same employer).\n - \"no\": clearly moved on (current employer is a different organization, even if related).\n - \"unknown\": the scraped data is missing, ambiguous, or you genuinely cannot tell.\n\nTreat parent/subsidiary as same employer ONLY when the on-record name is a parent and current is its direct subsidiary AND the role is clearly continuous. When in doubt, return \"unknown\" rather than guessing.\n\nProvide a brief reasoning sentence.", "output_format": { "verdict": { "type": "string", "description": "one of: yes, no, unknown" }, "reasoning": { "type": "string", "description": "brief one-sentence justification" } } }, "run_if": { "variable": "{{.formatted_data}}", "operator": "is not", "values": ["yes", "scrape_failed"], "continue_workflow_if_run_condition_not_met": true } } Why explicit "unknown" in the LLM's verdict vocabulary: without it, the LLM is forced into a binary answer on ambiguous data ("couldn't scrape any history but they're probably still there"). Explicit "unknown" lets the LLM decline to guess; the verdict column carries that uncertainty forward for human review. STAGE 4 — Changed Company (consolidator, JS) Action: format_data_using_js_expression (() => { const ruleVerdict = "{{.formatted_data}}"; if (ruleVerdict === "scrape_failed") return "scrape_failed"; if (ruleVerdict === "yes") return "no"; // still at company → didn't change const llmVerdict = "{{.verdict}}"; if (llmVerdict === "yes") return "no"; // LLM says still → didn't change if (llmVerdict === "no") return "yes"; // LLM says moved → changed if (llmVerdict === "unknown") return "unknown"; // LLM didn't run or produced nothing usable — fall back to rule (flipped). if (ruleVerdict === "no") return "yes"; return "unknown"; })() Polarity flip rationale: rule and LLM internally answer "is the person still at the company?" (yes = still). The user-facing answer column reverses to "Changed Company" (yes = moved on) because the primary use case is surfacing movers. Flipping at the consolidator rather than at every upstream step keeps the rule + LLM prompts natural. STAGE 5 — Title Changed? (rule check, JS) Action: format_data_using_js_expression Internally gated on Known Job Title presence — no `run_if`. (() => { const normalize = (str) => { if (!str) return ""; return String(str).toLowerCase() .replace(/[‘’]/g, "'") .replace(/[“”]/g, '"') .replace(/[–—]/g, "-") .normalize("NFD").replace(/[̀-ͯ]/g, "") .replace(/[.,&]/g, "") .replace(/\s+/g, " ") .trim(); }; const known = normalize("{{input.known_job_title}}"); const current = normalize("{{.person_current_job_title}}"); if (!known) return "skipped"; // user didn't supply a title to compare if (!current) return "unknown"; // scrape couldn't read current title if (known === current) return "no"; if (known.includes(current) || current.includes(known)) return "no"; return "yes"; })() Polarity: `yes` = title changed, `no` = title unchanged. Combined with `Changed Company = no + Title Changed = yes` you get the "promoted internally" / "lateral move" signal cleanly. STAGE 6 — Title Change Message (human-readable summary) Action: format_data_using_js_expression (() => { const verdict = "{{.formatted_data}}"; if (verdict === "yes") { const known = "{{input.known_job_title}}"; const current = "{{.person_current_job_title}}"; return "Title changed from " + known + " to " + current; } if (verdict === "no") return ""; if (verdict === "unknown") return "Unknown"; if (verdict === "skipped") return ""; return ""; })() Empty string for `no` and `skipped` so downstream Slack / digest templates can drop the line entirely instead of rendering a noisy "No change" tag. ================================================================================ 6. BEST PRACTICES ================================================================================ - **Re-scrape on a schedule that matches signal half-life.** Weekly is typical. LinkedIn data updates slowly; daily re-scrapes burn the scrape credit without catching meaningful new signal. - **Always use `continue_workflow_if_action_fails: true` on the scrape.** Without it, even routine 404s halt the row and bury the `scrape_failed` signal under a generic `has_failures` row status. - **Branch downstream on the verdict cell, not `row_status`.** `row_status: has_failures` is correct for `scrape_failed` rows (the scrape cell genuinely failed) but downstream consumers should read the `Changed Company` cell's value — not the row's status — to avoid losing visibility into the legitimate-but-flagged rows. - **Validate flagship LinkedIn URLs upstream.** Sales Nav opaque URLs (`linkedin.com/in/ACoAA...`) get rejected by scrape providers. If your CRM contains these, normalize via `enrich_person_linkedin_profile` once with the opaque URL to resolve, then keep the flagship form thereafter. - **LLM model choice.** `gpt-4o-mini` at ~$0.0001/row is the right default — fast, cheap, good enough on well-known rebrands and parent/sub relationships. Reach for Claude Sonnet or GPT-5 only if you find the LLM misjudging cases in your specific domain. - **Spot-check LLM-overridden verdicts.** When the rule says "no" and the LLM flips to "yes" (or vice versa), it's worth scanning the LLM's `reasoning` field. Persistent miscalls indicate the prompt needs tightening. - **Carry the source LinkedIn data forward.** The `enrich_person_linkedin_profile` cell has the full scraped payload — `current_company_name`, `person_current_job_title`, `experiences` array. Reference these directly in any custom downstream JS rather than re-scraping. ================================================================================ 7. COMMON VARIATIONS ================================================================================ - **"Congrats on the new role" sequencer.** Filter downstream on `Changed Company = yes`, enrich the new company's domain (e.g. `enrich_company_linkedin_profile` + `person_work_email_waterfall` for the new work email), then push to a sequencer with a templated congratulations message referencing the title. - **Promoted-internally Slack ping.** Filter on `Changed Company = no AND Title Changed = yes`. Often a stronger signal than the "moved on" bucket — same relationship, fresh context. Push the `Title Change Message` to a dedicated channel. - **CRM write-back.** On `Changed Company = yes`, update the CRM contact via `hubspot_update_object` / `salesforce_update_record`. Move the previous company name into a "previous_company" field for audit; replace the current company / title with the scraped values. - **Retry-on-fail loop.** For `Changed Company = scrape_failed`, push the row to a "Retry" sub-sheet with a `delay_step` of 7 days and re-scrape. Many "dead URL" rows turn out to be transient rate limits or temporary profile locks. - **Tighten the title-change normalizer.** Real-world title fields often have noise (`Manager, Head` vs `Manager | Head` — same job, different separator). If you see many noisy "changes" in real data, add `|`, `/`, `–`, `—`, etc. to the punctuation strip in the title check's `normalize()` and re-run. - **Surface previous title explicitly.** Add a JS step that reads the second-most-recent experience from the scrape's `experiences` array and exposes the previous title. Useful when the title change is recent and you want to show both sides without relying on the CRM's Known Job Title (which may be stale). - **Pair with hiring_signal_outbound for two-sided coverage.** Use the hiring-signal flow to discover *new* contacts at target companies; use this flow to monitor *existing* contacts for moves. Together they keep your CRM live without manual research. ================================================================================ 8. FAILURE MODES AND MITIGATIONS ================================================================================ - LinkedIn scrape returns garbage / wrong person. Rare with flagship URLs; common with Sales Nav opaque URLs. Mitigation: validate URLs are flagship form (`linkedin.com/in/`) upstream. Add a JS filter on URL format upstream of the scrape. - LinkedIn rate-limits at scale. Tens of thousands of contacts scraped per day may hit provider quotas. Mitigation: add `delay_step` between rows for sustained throughput, or split contacts across multiple sheets running on staggered schedules. Monitor `scrape_failed` rate per run — a spike usually means rate-limit hit, not data quality. - False negative on company change due to rebrand the LLM doesn't catch. Common ones (Twitter → X, Facebook → Meta, Slack → Salesforce) are well-known to gpt-4o-mini. Niche or recent rebrands may slip. Mitigation: maintain a rebrand dictionary in a sub-sheet and do a `lookup_another_floqer_workflow_row` against it as a final override step. When on-record and current names both appear paired in the dictionary, override `Changed Company = yes` to `no`. - LLM hallucinated "yes" for a parent/sub that's actually different. The prompt explicitly says "treat parent/subsidiary as same employer ONLY when... AND the role is clearly continuous", but LLM judgment is imperfect. Mitigation: spot-check LLM-derived `Changed Company = no` rows where the rule check originally said `no`. Tighten the prompt if you see consistent failures. - Title change message floods Slack with minor wording tweaks (`Capabilities` → `Capability`, `–` → `|`, etc.). Mitigation: tighten the title normalizer (see Common Variations), or insert a downstream LLM classifier that grades title changes as `promotion` / `lateral` / `cosmetic` and only routes promotions to Slack. - `row_status: has_failures` on `scrape_failed` rows confuses downstream filtering. Mitigation: document loudly that the `Changed Company` cell is the authoritative status. Consumers should branch on `Changed Company`'s 4-value vocabulary, not on `row_status`. - Bidirectional includes false-positive on substring overlaps (e.g. "Apple" stored, current "Apple Music" — different employer, but bidirectional includes says yes). Mitigation: rare for well-curated CRM data where company names tend to be specific. If your data has many single-word generic names, add a length-based confidence guard to the rule check (e.g. only allow includes-match when the shorter name is ≥ 5 chars) — but expect to lose some legitimate "Acme" vs "Acme Inc" matches in trade. ================================================================================ 9. RELATED USE CASES ================================================================================ - (planned) Contact-only LinkedIn role-change detection — same scrape, but with no company-on-record. Detects recent role change or promotion from the scraped `experiences` array directly. Useful when the input is a bare LinkedIn list (e.g. event attendees, public influencer list) rather than a CRM export. - hiring_signal_outbound — outbound counterpart. Same person / company primitives, but triggered by an external hiring signal instead of a CRM list re-scrape. Use both together for two-sided coverage: new contacts via hiring signals, existing contacts via change detection. - account_research_and_scoring — when contact-level change feeds into account-level scoring (e.g. account loses its champion = score down). ================================================================================ This file is maintained manually. Last updated: 2026-05-14. Use case catalog: https://floqer.com/docs/use-case-catalog.txt Related action details: https://floqer.com/docs/action-detail/enrich_person_linkedin_profile.txt https://floqer.com/docs/action-detail/format_data_using_js_expression.txt https://floqer.com/docs/action-detail/llm_models.txt