--- name: annual-account-job-change-check description: Check whether contacts on closed-won annual accounts have changed jobs or left their company in the last 90 days, using the connected CRM (contacts, accounts, deal flags) and Firmable (current employment data) to detect the change. CRM-agnostic — detects whichever CRM is connected. Use this whenever the user asks which contacts have changed jobs, left their company, gone quiet, or need re-engaging on closed-won or annual accounts, or asks to check contact churn/turnover across customers. Trigger on phrases like "who's changed jobs", "who's left their company", "contact turnover on our accounts", "job changes on closed won accounts", or "stale contacts on annual accounts". --- # Annual account job-change check For every closed-won annual account in the CRM, find the active contacts, check each one's current employment in Firmable, and flag anyone who has changed companies or appears to have left in roughly the last 90 days. Output is a summary table in chat — this skill never writes back to the CRM or creates Firmable lists. ## Confirmed behaviour (apply every run) | Decision | Behaviour | |---|---| | "Annual account" flag | **Ask the user every run** which deal property/value marks an account as annual. Never reuse a flag from a previous session — portals get relabelled and this skill should never guess. | | Contact scope | Only contacts **marked active** in the CRM (i.e. not already flagged inactive/former/do-not-contact). Skip anything already flagged inactive — it's already been handled. | | Output | Summary table in chat only. No Firmable list is created, no CRM record is written, nothing gets pushed anywhere. | ## Workflow ### 0. Detect the connected CRM Check which CRM's tools are already loaded, or `tool_search` for "CRM" if nothing's loaded yet. This determines which reference file to use for steps 1 and 2: - **HubSpot** → `references/hubspot.md` - **Any other CRM (Salesforce, Pipedrive, MS Dynamics 365 Sales, etc.)** → `references/other-crms.md` ### 1. Ask which flag means "annual" Every single run, before querying anything, ask the user which deal property and value identifies an annual account (e.g. "billing_frequency = Annual", "contract_term = 12 months", a specific deal pipeline, etc.). To make this a quick answer rather than a blind guess, pull the deal property list first and surface a few likely candidates (anything with "annual", "term", "frequency", "billing", "cycle", "contract" in the name or label) so the user can just confirm or correct rather than typing a property name from memory: ``` get_properties({ objectType: "deals" }) ``` (Or the equivalent property-listing call for the connected CRM — see the reference file.) Then ask something like: "I see these deal properties that might flag annual accounts: `billing_frequency` (values: Monthly, Annual, Multi-year), `contract_term_months`. Which one, and what value, marks an account as annual?" Don't proceed to step 2 until the user has confirmed the exact property and value. If they say "just check all closed-won, there's no annual distinction," treat every closed-won account as in scope and say so in the output. ### 2. Pull closed-won accounts matching the confirmed annual flag Using the reference file from step 0, query for closed-won deals where the confirmed property matches the confirmed value, aggregated to unique accounts (a company shouldn't appear twice because it has two annual deals). ### 3. Pull active contacts for each account For each account from step 2, get its associated contacts, then filter to **only those marked active** — i.e. exclude anyone already carrying an inactive/former/do-not-contact flag in the CRM. The reference file for the connected CRM has guidance on what that flag typically looks like; if it's ambiguous which property represents "inactive," ask once rather than guessing (this is different from the annual-flag question, which is asked every run — the inactive-contact flag is more likely to be stable across runs, so it's fine to confirm once per conversation). Keep the account-to-contact mapping — you'll need to know which account each contact belongs to when presenting results. ### 4. Resolve each active contact to a Firmable person This is credit-chargeable. `bulk_get_people` costs roughly 1 credit per person — tell the user the expected cost (number of active contacts across all flagged accounts) before running it. ``` bulk_get_people({ items: [{ email: "jane@acme.com" }, { email: "sam@beta.io" }, ...] }) ``` For contacts without an email on file, fall back to a free `filter_search` match on name + company instead of spending a credit: ``` filter_search({ category: "people", filters: { person_id: { "contains:jane smith": "jane smith" }, company_id: { "": "Acme" } }, operators: { person_id: "AND", company_id: "AND" } }) ``` Drop and note any contact that can't be resolved (common for contacts who've been at the company a long time and were never re-enriched, or generic/shared inboxes). ### 5. Check for job changes (moved to a new company within ~90 days) Firmable's `current_role_duration` filter buckets people by how long they've been in their current role, which is a more reliable signal of recency than trying to read a raw date out of a profile. Resolve the band that corresponds to roughly the last 90 days: ``` get_filter_options({ identifier: "current_role_duration" }) ``` Pick whichever band(s) cover "0-3 months" or equivalent. Then check which of the resolved contact ids fall in that band **and** whose current company (from the fresh Firmable profile) differs from the company recorded against them in the CRM: ``` filter_search({ category: "people", filters: { person_id: { "": "Jane Smith", "": "Sam Lee", ... }, current_role_duration: { "": "0-3 months" } }, operators: { person_id: "OR" } }) ``` Anyone returned here, whose current company doesn't match their CRM account, is a **job change**: they've moved to a new company recently. ### 6. Check for "left company" (no confirmed new employer yet) This is the harder case — someone who's left has no current-role tenure to bucket, so step 5's method doesn't catch them. Instead, look at each resolved contact's fresh Firmable profile from step 4 for signals that they're no longer at the CRM's on-file company: - Current company field is empty, or missing entirely from the profile - Current company differs from the CRM's on-file company but there's no clear new-role tenure signal either (distinguish this from step 5's confident "job change" case) - Title or profile text uses past-tense/former language Flag these as **"left company, destination unclear"** rather than asserting a hard 90-day window — Firmable's data doesn't reliably timestamp departures the way it timestamps new-role tenure, so don't claim precision you don't have. Say so plainly in the output. ### 7. Present the summary table One table, grouped by account, something like: ``` Checked 8 closed-won annual accounts (flag: billing_frequency = Annual), 34 active contacts, 31 resolved in Firmable. | Account | Contact | CRM title/company on file | Status | Detail | |---|---|---|---|---| | Acme Pty Ltd | Jane Smith | VP Marketing, Acme | Changed jobs | Now Head of Growth at Beta Corp (started ~6 weeks ago) | | Acme Pty Ltd | Sam Lee | IT Director, Acme | No change detected | Still at Acme per Firmable | | Delta Group | Priya Nair | CFO, Delta Group | Left company, destination unclear | No current employer on file in Firmable | 3 contacts could not be resolved in Firmable: [names]. 2 accounts had zero flagged contacts (all still current). ``` Always separate the confident "changed jobs" rows from the lower-confidence "left, destination unclear" rows rather than blending them, since the account owner needs to know which ones need real detective work versus a clean handoff to the new company. ## Edge cases worth knowing - **A contact changed jobs but stayed in a similar-sounding role at a similar-sounding company.** Don't rely on title text alone to decide "no change" — always compare the actual current company id/domain from Firmable against the CRM's on-file account, not just whether the job title looks the same. - **The confirmed annual flag returns zero accounts.** Say so and ask the user to double check the property/value rather than silently falling back to "all closed-won." - **A contact is associated with multiple accounts** (common for consultants or holding-company structures). Report them once per account they're tied to, since each account owner needs the flag independently. - **Credit cost surprises.** `bulk_get_people` in step 4 is the only credit-charged call in the whole workflow. Everything else (`filter_search`, `get_filter_options`, `get_properties`) is free. Quote the expected cost once, upfront, before running step 4. - **Don't cache the annual-flag answer across runs, even in the same day.** If the user runs this twice in one conversation, ask again — someone else on the team may have changed the property between runs, and the cost of asking is one question versus the cost of silently querying the wrong deals. ## Why this skill exists Job changes and departures at existing customers are one of the most common causes of quiet churn — the deal was won, the account looks healthy in the CRM, but the actual champion is gone and nobody noticed until renewal. Firmable already has fresher employment data than a CRM contact record that only gets updated when someone manually notices. Cross-referencing the two turns a manual, easy-to-forget check into something that can run on demand before a renewal cycle or QBR. --- # Reference: HubSpot # HubSpot: closed-won annual accounts and their active contacts Use this reference when HubSpot is the connected CRM (i.e. `HubSpot:*` tools are loaded). ## Step 1 aid: surfacing candidate "annual" properties ``` get_properties({ objectType: "deals" }) ``` Scan the returned property list for names/labels containing "annual", "term", "frequency", "billing", "cycle", or "contract" and surface 2-4 likely candidates with their enumeration values (if any) to the user. Still wait for their explicit confirmation before filtering on any of them. ## Step 2: closed-won accounts matching the confirmed flag First resolve the closed-won stage id(s) — don't assume the label, pipelines get added: ``` get_properties({ objectType: "deals", propertyNames: ["dealstage"] }) ``` Then query, substituting the user-confirmed property name and value: ```sql SELECT DISTINCT COMPANY.name, COMPANY.domain, COMPANY.hs_object_id FROM DEAL WHERE dealstage IN ('', '') AND = '' ``` Use `query_crm_data` for this. If the confirmed property lives on a different object (e.g. a company-level or line-item-level property rather than a deal property), adjust the `FROM`/join accordingly — ask the user which object the property is on if it's unclear from `get_properties`. ## Step 3: active contacts per account ``` search_crm_objects({ objectType: "contacts", filterGroups: [{ associatedWith: [{ objectType: "companies", operator: "EQUAL", objectIdValues: [] }] }], properties: ["email", "firstname", "lastname", "jobtitle", "lifecyclestage", "hs_lead_status"] }) ``` "Active" typically means the contact does NOT carry a status like "former employee," "do not contact," or a custom inactive flag. Common properties to check (varies by portal — confirm once with the user if ambiguous): - A custom property literally named something like `contact_status` or `is_active` - `hs_lead_status` set to a disqualifying value - A "do not contact"/`hs_email_optout` type flag Exclude contacts where any such flag is already set to an inactive/former state. If none of these properties exist on the portal, treat all associated contacts as active and say so. ## Notes - Batch the contact lookup across accounts rather than one call per account if the account count is large — `search_crm_objects` supports up to 5 filter groups per call, so group a handful of company ids per call using `IN` on `objectIdValues` where possible. - Always pull `email` for each contact — it's the primary key used to resolve the contact in Firmable in step 4 of the main workflow. --- # Reference: Other CRMs # Other CRMs: Salesforce, Pipedrive, MS Dynamics 365 Sales No MCP tool schema is baked into this skill for these — work it out live, the same way as the HubSpot reference does conceptually: 1. **Find the CRM's tools.** `tool_search` for the CRM's name (e.g. "Salesforce opportunities", "Pipedrive deals") to load whatever query/read tools are connected. Tool names and schemas differ per CRM MCP. 2. **Find the "annual flag" property.** The user will tell you the exact property/value each run (step 1 of the main workflow always asks). Use whatever property-listing tool the connected CRM offers (Salesforce: describe the `Opportunity` or `Contract` object; Pipedrive: list deal fields; MS Dynamics: list opportunity attributes) to surface likely candidates before asking, the same way `get_properties` does for HubSpot. 3. **Find the equivalent of "closed-won."** - **Salesforce**: `Opportunity` where `IsWon = true`, filtered further by the confirmed annual property (which may live on the Opportunity or a related Contract/Order object). - **Pipedrive**: `Deal` where `status = 'won'`, filtered by whatever custom field marks annual billing. - **MS Dynamics 365 Sales**: `Opportunity` where `statecode = Won`, filtered by the confirmed property. 4. **Aggregate to unique accounts**, not individual deals/opportunities — a company with two annual deals should only appear once. 5. **Pull contacts per account** using whatever association/relationship the CRM exposes (Salesforce: `Contact.AccountId`; Pipedrive: `Person` linked to `Organization`; MS Dynamics: `Contact.parentcustomerid`). 6. **Determine "active."** Each CRM has its own convention: - **Salesforce**: often no single standard field — check for a custom checkbox/picklist, or whether `Contact.IsDeleted`/converted-lead status applies. Confirm once with the user if unclear. - **Pipedrive**: `Person` records don't have a strong built-in active/inactive flag by default — check for a custom field. - **MS Dynamics**: `statecode`/`statuscode` on the Contact entity often distinguishes active vs inactive. Confirm the "active contact" field once per conversation if it's not obvious — this is different from the annual-flag question in step 1, which must be asked fresh every run regardless of CRM. If a query capability genuinely doesn't seem to exist in the connected tool set, say so plainly rather than guessing at syntax, and ask the user whether they want to point you at a specific tool, view, or export instead.