USE_CASE_ID: still_at_company_outbound_gate NAME: Still-at-company gate before outbound (multi-role aware) CATEGORY: Outbound prospecting The user is running an outbound / persona-enrichment chain (LinkedIn URL -> scrape -> email / phone / CRM push -> sequencer) and wants to stop contacting people who have left the company they were sourced for. A prospect list goes stale between sourcing and outreach: the person you found as "VP Sales at Acme" may have moved on by the time the chain reaches them. Emailing them "I saw you lead sales at Acme" burns a first impression and a send credit. This is an INLINE gate, not a monitoring report. It sits mid-chain, right after the LinkedIn scrape and before the first paid downstream step, and silently drops rows where the person is confidently no longer at the target company. Surviving rows continue to outreach unchanged. The distinguishing design choice is MULTI-ROLE awareness. People hold several concurrent roles (founder + advisor + non-exec director). LinkedIn's single `current_company_name` field surfaces only the ONE role LinkedIn flags as primary — which is often NOT the company you sourced them for, even when they still hold that role. Keying the gate on `current_company_name` therefore drops real, still-valid contacts. This use case keys on the `experiences` array instead: it extracts EVERY current role (every stint with no end date) and asks whether the target company is among them. 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: - There is an existing outbound chain that scrapes each prospect's LinkedIn (enrich_person_linkedin_profile) and then spends on downstream paid steps (email waterfall, phone waterfall, CRM create/update, sequencer enrollment). - The prospect was sourced for a SPECIFIC company (you have a company_name on the row) and you want to suppress outreach to anyone who has since moved on. - You want this to happen automatically, in-line, with no separate review pass — the chain should just skip movers. DO NOT USE WHEN: - You want a per-contact verdict REPORT for CRM hygiene / routing (movers, promotions, retries) rather than a silent drop. Use https://floqer.com/docs/use-case-detail/crm_contact_change_detection.txt — it emits a 4-value Changed Company verdict + a title-change signal and is built for routing, not gating. - There is no company on record to compare against (bare LinkedIn list). The gate has nothing to anchor on. - The prospect was sourced at the ACCOUNT level and any contact at the account is acceptable — then "did this specific person move?" is the wrong question. ================================================================================ 2. INPUTS AND PRE-FLIGHT CLARIFICATIONS ================================================================================ INPUTS (already present on the outbound sheet — this gate adds no new inputs) Required: company_name (string) — the company the prospect was sourced for. This is the reference the gate matches against. The scrape cell (enrich_person_linkedin_profile) upstream, which supplies the `experiences` array, `current_company_name`, and `person_current_job_title`. Optional but recommended: A "clean company name" upstream step (llm_models) that normalizes company_name for greetings. If present, use its output as the primary reference with raw company_name as fallback. If absent, use raw company_name directly — the gate's own LLM normalizes fuzzily anyway, so a dedicated clean step is not required. PRE-FLIGHT CLARIFICATIONS - **Drop vs flag.** Default is DROP (the gate filters movers out of the chain). If the user wants to keep every row but tag it, skip the filter and expose the verdict as a column instead (see Common Variations). - **Drop bias.** Default keeps anyone the LLM is not CONFIDENT has left — ambiguous and missing-data rows pass through, so a parse gap never nukes a good lead. Confirm whether the user instead wants the stricter "keep only confident yes" polarity (drops more, including uncertain rows). - **Placement.** Confirm the first PAID downstream step so the gate can sit immediately before it. The whole point is to gate before spend. - **Already-sent contacts.** This gate affects new and re-run rows only. It does not retract anyone already pushed to a sequencer. ================================================================================ 3. OUTPUTS ================================================================================ Current roles (string, from the JS extractor): "|"-separated list of every company where the person currently holds an active role (no end date). "NONE_FOUND" when the scrape produced no parseable current role. Still at company? (string, from the LLM): "yes" — target company matches one of the current roles, OR the data is ambiguous / missing (keep-on-doubt default). "no" — confidently moved on: current-roles list is non-empty and the target company is clearly not among them. The filter itself produces no column; it sets the row's cell to `condition_not_met` for dropped (moved-on) rows, which halts the chain for those rows. Surviving rows carry on to the paid steps. Note the polarity is KEPT as "still at company" (yes = keep) end to end — no flip. This gate keeps qualified people, so "yes = continue" reads naturally. (Contrast crm_contact_change_detection, which flips to "Changed Company" because its product is a movers report.) ================================================================================ 4. WORKFLOW DESIGN ================================================================================ Three actions inserted into the existing chain, between the scrape (and any "drop dead profiles" filter) and the first paid step: ... -> enrich_person_linkedin_profile -> [filter: drop dead profiles] (if already present) -> format_data_using_js_expression "Current roles (from experiences)" -> llm_models "Still at company? (LLM check)" -> filter "Drop people no longer at target company" -> person_work_email_waterfall / phone / CRM push / sequencer ... Key design decisions: - **Extract current roles in JS, match in the LLM.** Splitting the two keeps each step honest. The JS step is deterministic: it walks `experiences` and emits ONLY companies with an active (no-end-date) role, so the LLM never sees past employers it could wrongly match against. The LLM then does just the fuzzy name comparison (suffixes, subsidiaries, taglines, translations) against that pre-filtered list. - **Why not just use current_company_name.** It is a single value — the one role LinkedIn flags as primary. A founder who is also a non-exec director elsewhere may surface the OTHER company there. Validated live: a prospect sourced for "Ouinex" had current_company_name = "Kunapak", but experiences showed current roles "Kunapak | Leasy Mat | Ouinex | Soldev" — still at Ouinex, correctly kept. A current_company_name check would have dropped them. Same pattern with "Profusion" surfacing under "Atombit". - **Place before the first paid step.** The scrape already ran; the gate adds one cheap LLM call (~$0.0001/row) and saves the email waterfall / phone waterfall / CRM-write spend on every mover. Rows run concurrently, so the added step does not lengthen wall-clock meaningfully. - **Drop only a confident "no".** The filter keeps everything that is not exactly "no", so "yes" and any ambiguous/empty-data verdict survive. Biasing toward keeping avoids silently nuking good leads on a scrape parse gap — you would rather occasionally email a mover than drop a valid prospect. ================================================================================ 5. IMPLEMENTATION ================================================================================ QUICK REFERENCE — the three inserted actions (names + refs as built on the reference outbound workflow; substitute your own upstream instance ids): format_data_using_js_expression "Current roles (from experiences)" reads: {{.experiences}} output: "|"-joined current roles as "Name [domain]" (domain when present), or "NONE_FOUND" llm_models "Still at company? (LLM check)" model: gpt-5.4-mini (any cheap model) reads: target {{input.company_name}} + {{input.company_domain}} free-text (no output_format) -> generated_content = "yes" | "no" matches on distinctive brand root + domain (sister entities kept) run_if: none (always runs; handles NONE_FOUND internally) filter "Drop people no longer at target company" path_conditions: {{.generated_content}} is not "no" continue_workflow_if_action_fails: true STAGE 1 — Current roles (from experiences, JS) Action: format_data_using_js_expression (() => { var raw = {{.experiences}}; var stints = Array.isArray(raw) ? raw : (raw && typeof raw === "object" ? Object.values(raw) : []); var out = []; stints.forEach(function (st) { st = st || {}; var name = ((st.company || {}).name || "").trim(); if (!name) return; // CURRENT when the stint OR any of its sub-positions has no end date var ends = [(st.date || {}).end].concat( (st.profilePositions || []).map(function (p) { return (p.date || {}).end; }) ); var isCurrent = ends.some(function (e) { return e === null || e === undefined || e === ""; }); if (!isCurrent) return; var dom = ((st.company || {}).domain || "").trim(); var label = dom ? (name + " [" + dom + "]") : name; if (out.indexOf(label) === -1) out.push(label); // every current role, no repeats }); return out.length ? out.join(" | ") : "NONE_FOUND"; })() Notes: - `experiences` is a raw_array; inject it UNQUOTED so you get the live JS array (see https://floqer.com/docs/action-detail/format_data_using_js_expression.txt §1, the array/object example). Double-quoting it would coerce it to "[object Object]". - Each item is a stint: { company:{name,domain,...}, date:{start,end}, profilePositions:[{title, date:{start,end}}] }. A stint is CURRENT when its `date.end` is null/empty, OR any of its profilePositions has a null/empty end (covers people who changed roles within the same current employer). - EMIT THE DOMAIN. When `company.domain` is present the role is written as `Name [domain]` (e.g. "Mswipe Capital [mswipe.com]"). The domain is the strongest match signal the LLM gets in Stage 2 — it disambiguates sister entities and rebrands that a name compare alone would miss. `company.domain` is often empty on smaller / spun-out entities, so the LLM still falls back to brand-root name matching when it's absent. - Multi-role aware: EVERY current stint is collected (deduped), not just LinkedIn's single primary — a founder + advisor + NED comes out as "A | B | C". Matching any one keeps the row. - Defensive Object.values handles the case where the array arrives as a row-indexed object. STAGE 2 — Still at company? (LLM check) Action: llm_models Reference resolution: use the cleaned company name if you have an upstream clean-company step, with raw company_name as fallback; otherwise reference raw company_name directly. Config: { "inputs": { "model": "gpt-5.4-mini", "prompt": "You verify whether a sales prospect is STILL at a target company, so we don't email someone who has moved on.\n\nTARGET COMPANY (sourced for):\n name: {{input.company_name}}\n domain: {{input.company_domain}}\n\nTHE PERSON'S CURRENT ROLES (companies where they hold an active role right now; \"|\"-separated). Each may include a domain in square brackets, e.g. \"Mswipe Capital [mswipe.com]\". \"NONE_FOUND\" means current roles could not be determined:\n {{.formatted_data}}\n\nLinkedIn's single flagged current title/company (context only): {{.person_current_job_title}} at {{.current_company_name}}\n\nTASK: Decide if the TARGET COMPANY matches ANY of the person's current roles. Match GENEROUSLY as the SAME employer when:\n- DISTINCTIVE BRAND ROOT matches. Ignore generic descriptor words (Technologies, Capital, Payments, Finance, Solutions, Services, Bank, Group, Holdings, Labs, India, Global, Pvt, Private, Ltd, Limited, LLC, Inc, PLC, GmbH) and legal suffixes when comparing — match on the distinctive brand token. e.g. \"Mswipe Capital\" and \"Mswipe Technologies\" are BOTH \"Mswipe\" -> match. But do NOT match on a shared GENERIC word alone (\"First Metro\" is NOT \"First Capital\").\n- DOMAIN matches. If a current role's bracketed domain equals or shares the registrable root domain with the target domain (e.g. both resolve to mswipe.com) -> match.\n- Country/region subsidiaries, taglines, abbreviations, non-Latin or translated renderings of the same brand -> match.\nA person may hold several current roles at once — a match with ANY one counts as still employed.\n\nIMPORTANT — DOMAIN IS A POSITIVE SIGNAL ONLY. A DIFFERENT domain is NOT evidence of a different employer when the distinctive brand root matches. Treat different TLDs/ccTLDs that share the same second-level label as the SAME company across regions (e.g. tala.co, tala.ph, tala.com are all \"Tala\"; foo.in and foo.com are the same \"foo\"). Use the domain only to ADD confidence — NEVER turn a clear brand-root match into a \"no\" because the domains differ.\n\nRULES:\n- Clear brand-root OR domain match with a current role -> yes.\n- Only answer no when you are CONFIDENT they left: the current-roles list is non-empty AND none share the target's distinctive brand root or domain label.\n- If current roles is empty or NONE_FOUND, or the brand root partially matches and you are unsure -> yes (never drop on doubt).\n\nOutput ONLY one lowercase word: yes or no. No punctuation, no explanation." } } Free-text mode (no output_format) returns the answer on `generated_content`. Pass BOTH the target name and the target domain — the domain is what lets the gate recognize a sister entity / rebrand whose current-role name differs (Mswipe Capital vs Mswipe Technologies). If you have an upstream clean-company step, you can swap {{input.company_name}} for its output, but it is not required — the brand-root rule normalizes suffixes on its own. STAGE 3 — Drop people no longer at target company (filter) Action: filter Keep every row whose verdict is NOT exactly "no". Movers (verdict = "no") fail the condition and the chain halts for them; everyone else continues. Config: { "inputs": { "path_conditions": [ { "conditions": [ { "variable": "{{.generated_content}}", "operator": "is not", "values": ["no"] } ] } ] }, "continue_workflow_if_action_fails": true } Operator dialect: the `filter` action uses `is` / `is not` (not `is equal to` / `not equal to`). The wrong dialect is silently accepted but never matches. ROLLOUT NOTE — when inserting into a chain that already has rows, run the FIRST new action (the JS extractor) with run_next_action: true to force-cascade the new cells down. Re-running the whole row via Run Rows can mark the row complete before the newly-added cells execute. ================================================================================ 6. BEST PRACTICES ================================================================================ - **Gate before spend, not after.** Place the three actions immediately before the first paid step (email/phone/CRM/sequencer). Gating after them defeats the purpose. - **Keep the keep-on-doubt bias unless told otherwise.** Most teams prefer occasionally emailing a mover over silently dropping valid leads. Only switch to "keep only yes" if false-positive sends are expensive (e.g. a tiny, high-touch list). - **Reuse the clean-company step if the workflow has one.** It gives the LLM a tidier reference (tagline/suffix already removed) and costs nothing extra. But do not add one solely for this gate — the match LLM normalizes fuzzily on its own. - **Read the per-cell status, not row_status.** Dropped rows show the filter cell as `condition_not_met`; `row_status` may read `has_failures` from an upstream best-effort cell. Branch on the filter cell. - **Match on the distinctive brand root + domain, not the exact legal entity.** People move between sister entities under one brand (Mswipe Technologies -> Mswipe Capital) and a strict name compare drops them as movers when they never really left. The Stage 1 extractor emits `Name [domain]` and the Stage 2 prompt matches on the distinctive brand token (ignoring Technologies / Capital / Payments / Bank / Pvt / Ltd …) and on the bracketed domain. Guard rail: never match on a shared GENERIC word alone ("First Metro" is not "First Capital"). Validated live 2026-06-10: a prospect sourced for "Mswipe Technologies" whose only current role was "Mswipe Capital" (no domain on that stint) was wrongly dropped by an exact-name prompt and correctly kept once the brand-root rule was added. - **Spot-check "no" verdicts early.** On the first run, scan a sample of dropped rows to confirm the LLM is not over-dropping subsidiaries or rebrands. Tighten the prompt if so. ================================================================================ 7. COMMON VARIATIONS ================================================================================ - **Flag instead of drop.** Remove the filter and keep the LLM verdict as a `still_at_company` column. All rows continue; segment or review downstream. Useful when the user wants visibility rather than suppression. - **No clean-company step.** Point the LLM reference at {{input.company_name}} only (drop the "cleaned:" line). Validated on a workflow with no upstream clean step — the LLM still correctly dropped genuine movers and kept multi-role matches. - **Stricter polarity.** To drop ambiguous rows too, flip the filter to keep only `is "yes"` and instruct the LLM to answer "no" when unsure. Higher precision on "still there", lower recall. - **Title gate too.** If the prospect was sourced for a specific SENIORITY (not just a company), add a parallel check that the current title at the target company still matches the sourced role — reuse the title-change rule check from crm_contact_change_detection (§5 Stage 5) but gate on it. - **Surface why a row was kept.** Have the LLM also return the matched current-role company (output_format with a second field) so reps can see WHICH role kept the prospect in — handy for the multi-role founders. ================================================================================ 8. FAILURE MODES AND MITIGATIONS ================================================================================ - experiences injected wrong -> empty current-roles list. If the JS references {{.experiences}} in double quotes it coerces to a string and the loop finds nothing, so everyone reads NONE_FOUND and nobody is ever dropped (silent no-op). Mitigation: inject UNQUOTED. Verify on a known multi-role profile that the extractor emits the expected "|"-joined list. - Over-dropping a sister entity / subsidiary / rebrand. The person moved between entities under the SAME brand (Mswipe Technologies -> Mswipe Capital) so an exact-name compare reads them as a mover and drops them, even though they never left the brand. Mitigation: BUILT INTO THE CURRENT PROMPT — Stage 2 matches on the distinctive brand root and on the Stage-1-emitted `[domain]`, and keep-on-doubt biases against a false drop. Note the domain is the stronger signal but is often empty on the spun-out entity (the Mswipe Capital stint had no domain), so the brand-root rule is what actually saves these — keep it. For stubborn niche rebrands with no shared token at all (e.g. "Meta" vs "Facebook"), add an explicit example to the prompt or a rebrand-dictionary lookup override (see crm_contact_change_detection §8). Guard rail: do NOT loosen so far that a shared GENERIC word matches ("First Metro" vs "First Capital") — that swings the gate to over-KEEPING movers. - Over-dropping on a domain MISMATCH (regional ccTLD). The brand root matches but the current-role domain differs from the target domain, and the model treats the difference as proof of a different company. Validated live 2026-06-10: sourced for "Tala Philippines" (tala.ph), the prospect's only current role was "Tala [tala.co]" — same brand, different regional TLD — and an earlier prompt dropped her. Different TLDs/ccTLDs sharing the same second-level label (tala.co / tala.ph / tala.com) are the SAME company across regions. Mitigation: BUILT INTO THE CURRENT PROMPT — domain is a POSITIVE signal only; a domain difference never overrides a clear brand-root match into a "no". Note this is the SAME failure class as the sister-entity case above, just surfaced via the domain rather than the name. Calibration on a 91-row run: 9 dropped, 8 correct (incl. five "Federal Bank" people now at "Airtel Payments Bank", correctly NOT matched on the generic word "Bank"), 1 false drop (Tala) fixed by this clause — the rest held. - Person genuinely has no current role (between jobs). experiences has only ended stints -> current list non-empty? No: every stint has an end date, so the list is empty -> NONE_FOUND -> kept by the keep-on-doubt rule. Decide if you want to drop the unemployed instead (they have no company to be "at"); if so, treat NONE_FOUND as "no" in the LLM rules. - Gate runs but already-sent contacts stay sent. The filter only affects rows that pass through it now; prior sequencer pushes are not retracted. Mitigation: if retroactive suppression matters, re-run the existing rows through the gate and reconcile against the sequencer (e.g. pause/remove movers there). - LLM returns more than the single word. With a tightened prompt gpt-5.4-mini reliably returns "yes"/"no", but a stray period or capitalization would dodge the `is not "no"` filter and keep a mover. Mitigation: the prompt forces one lowercase word; if you see drift, normalize the verdict in a one-line JS step before the filter (lowercase + trim + strip punctuation). ================================================================================ 9. RELATED USE CASES ================================================================================ - crm_contact_change_detection — the REPORT counterpart. Same scrape and same "still at company?" question, but emits a 4-value verdict (+ title-change signal) per contact for routing movers / promotions / retries, instead of silently gating a chain. Use that when the deliverable is a movers report or CRM write-back; use this when the deliverable is a clean outbound send. - icp_outbound_prospecting — the outbound chain this gate plugs into. Source an ICP list, enrich, then insert this gate before the sequencer step so stale prospects never get contacted. - hiring_signal_outbound — discovers NEW contacts at target companies. Pair the two: hiring signals bring fresh contacts in, this gate keeps already-sourced ones from going stale before send. ================================================================================ This file is maintained manually. Last updated: 2026-06-10. 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 https://floqer.com/docs/action-detail/filter.txt