Find contacts at accounts showing buying signals
When to use it: an account left a review, replied to a form or showed some other signal you record, and it now sits in your CRM with nobody linked to write to, so nobody ever follows up. The CRM already knows which accounts raised a hand and a people search knows who works at each one. The gap between them is mechanical (find, dedupe, link, queue), which makes it a job to run on a schedule instead of a line on a to-do list.
Scheduled routine, weekly
"Find the right people at every account that showed interest but has nobody on file."
│
▼
┌─────────────────────────────────────────────────┐
│ 1 · List signal accounts with no contact │ folk_group, folk_record
│ The signal segment, minus every account that │ one people search for the whole segment
│ already has someone linked. │
└────────────────────────┬────────────────────────┘
▼
┌─────────────────────────────────────────────────┐
│ 2 · Find candidates for each role │ linkedin_aiark_search
│ Search each account's domain from its record │ titles filtered client-side, not by the API
│ and match titles on whole words. │ a page budget per account
└────────────────────────┬────────────────────────┘
├───────────────▶ ▪ no domain no website or work email on the record
▼
╔═════════════════════════════════════════════════╗
║ 3 · Keep the most senior per role ║ current employer checked first
║ One current employee per role, on title fit ║
║ and seniority, never on result rank. ║
╚════════════════════════╤════════════════════════╝
├───────────────▶ ▪ nobody found no candidate left for any role
▼
┌─────────────────────────────────────────────────┐
│ 4 · Get a work email │ linkedin_aiark_person
│ One export per kept person, and an address off │ a timeout is not a not-found
│ the account's domain is held. │
└────────────────────────┬────────────────────────┘
├───────────────▶ ▪ no email none found, or at another domain
▼
╔═════════════════════════════════════════════════╗
║ 5 · Spot people already in the CRM ║ folk_record
║ Every address is matched before anything is ║ the full address, never the name
║ created, so a match gets updated. ║
╚════════════════════════╤════════════════════════╝
▼
┌─────────────────────────────────────────────────┐
│ 6 · Save each contact to the CRM │ folk_record
│ Create or update, link to the account, and add │ batches of 50, failed items retried alone
│ to the segment's group. │ companies read back after every write
└────────────────────────┬────────────────────────┘
▼
╔═════════════════════════════════════════════════╗
║ 7 · Check the campaign holds new leads ║ lemlist_campaign, lemlist_create_lead
║ A probe lead on your own address shows whether ║ reports read before and after the probe
║ an added lead sends unreviewed. ║ the probe is deleted either way
╚════════════════════════╤════════════════════════╝
├───────────────▶ ▪ auto-review campaign sends leads on add
▼ leads wait for review
┌─────────────────────────────────────────────────┐
│ 8 · Queue each contact for outreach │ lemlist_get_leads, lemlist_create_lead
│ Name, email, company and role into your │ deduplicated on the address
│ campaign, once each, held for review. │ reports read again after the batch
└─────────────────────────────────────────────────┘
▪ terminal — the row stops there and nothing further is spent on it
1. List signal accounts with no contact
folk_group— resolve the id of the group that holds your signal segment once, and keep it. Passing it asgroup_idmakes the search list that group's companies only; a search with neither a group nor filters fetches every matching page of a large workspace.folk_recordwithentity="company"andop="search"— the segment in one call, not every company filtered afterwards: this list is re-run on a schedule, so keep it cheap. A standard field narrows infilters. A group's custom field, such as a signal status or a review date, comes back undercustomFieldValueskeyed by group id, so read it from the result rather than guessing a filter key.countreports the real total, and a count above the number of results means the list was cut atmax_results.- Then one person search for the whole segment rather than one lookup per account:
entity="person"withfilters={"companies": {"in": [<the segment's company ids>]}}, since relation fields takein. Every company that comes back linked to someone leaves the list. What remains is the run. - Probe that filter on the first run before trusting it. The value shape for a relation filter isn't spelled out, and a shape the search misreads can make every account look contactless, or every account look covered. Run it once with a single company id you know has a linked person and confirm
countis at least 1 and that the people returned belong to that company; only then read a zero on the full list as "nobody linked". - Apply the same truncation check to that person search. It is cut at
max_resultstoo (100 by default), and a linked person who didn't come back leaves their company in the "no contact" list, so the run goes looking for people the CRM already has; step 5 only catches that on an exact address. Comparecountwith the number of results, and raisemax_resultsor split the id list into chunks until the two match. - Text filters are prefix matches, not contains:
{"name": "Blue"}finds "Blue Harbor", while{"name": "Harbor"}finds nothing. Never read a zero count on a name as proof that a record doesn't exist. The step after "doesn't exist" is a create, which is exactly how duplicates are made.
2. Find candidates for each role
- Take each account's domain from its company record: the website in its
urlsfirst, the domain of a work address in itsemailsotherwise, normalized (lower-case, no scheme, nowww., no path). A company created in the CRM by hand often carries neither, and a webmail domain on a shared inbox is not the company's. An account with no usable domain ends here as no domain, named in the report so someone can add its website, rather than being searched by name. - Define the two or three roles actually worth reaching for your product, commonly the founder or CEO and whoever owns the function your product touches (marketing, operations, procurement). Write each as a keyword list matched on whole words, in every language your accounts use, plus exclusion families: assistants, interns, recruiters and [adjacent functions that share a keyword with your buyer]. Whole words stop a short keyword from matching inside an unrelated title.
linkedin_aiark_searchwithop="people"andaccount={"domain": {"any": {"include": ["<domain>"]}}}, a plain list rather than the name-matching wrapper. Filter ondomain, neverwebsite: the tool refuseswebsitebecause the index would accept it, ignore it without a word and return the whole database as if it were your result.titleanddepartmentare refused for the same reason: the index would ignore them and bill you for the company's first page. Match titles client-side against the role lists;department.departmentsis present on every returned record even though it can't be filtered on.contact.seniorityis a level derived from the title, not the title itself, and it drops the right people wherever titles don't follow the hierarchy: a "Managing Director" does not carrydirector. Use it to cut pagination, never to select. For "works in this country", filter oncontact.location.account.locationis the headquarters, and a company headquartered elsewhere returns zero, which reads exactly like nobody matching.- Every returned record is billed, so
sizeis the spend bound. Project withfieldsusing the real top-level keys (id,profile,link,location,department,company): an unrecognized key is dropped silently, and the records then look empty rather than wrong. A read timeout is retried once and then raised withretryable: true, which is a failure and never an empty page. - Page to a budget, and say when the budget is what stopped you. Give each account [a page budget] at [a page size] and stop there. Results are not ordered by title, so on a large account the right person can sit on a page past the budget. Log a role unmatched after the last page of results as not found, and one unmatched when the budget ran out first as not found within budget: the first says nobody holds the role, the second only that this run didn't look far enough, and raising the budget for that account is the fix.
- A role with no match doesn't block the roles that did resolve.
3. Keep the most senior per role
- Check the current employer first. Keep only candidates whose current company on the record is this account's domain: a domain search can surface people whose position there has ended, or a sister company sharing a group domain. A role whose candidates all fail this check is logged as not found, the same as a role the search never matched.
- Where several plausible people fill one role, don't take the first result, because position in the results is not a reliable pick. Judge on title fit, then seniority. A "Head of Marketing" search that returns a marketing coordinator and a CMO keeps the CMO.
- One person per role. That choice is the difference between a contact worth emailing and one who forwards the message up the org chart.
- An account left with no candidate for any role once this check has run ends here as nobody found, named in the report with each role's not found or not found within budget, so the second kind can be searched again deeper.
4. Get a work email
linkedin_aiark_personwithop="export"and theidfrom the search: the synchronous email finder, billed per person, so it runs only on the people kept in step 3.{"found": false}is an absence, so write not found. An error carryingretryable: trueis a failure: nobody was looked up, so retry later and never record a not-found from it. Exports time out in bursts while search stays healthy, and the one automatic retry can bill a second time when only the answer was lost.- Gate the address on the account's domain. An email at a different domain, such as a side project, a previous employer or a personal address, is held back rather than used. A role whose only address fails this gate ends as no email, logged as held for a domain mismatch rather than as not found.
5. Spot people already in the CRM
folk_recordwithentity="person"andfilters={"emails": "<full address>"}. Search on the whole address and confirm the returned email equals it exactly: the match is a prefix, so one address can match a longer one.- Never deduplicate on name. A prefix match on a compound name returns zero for a person who exists, and the create that follows is a duplicate.
- A match means the person already exists, so update that record. No match means create a new one.
6. Save each contact to the CRM
folk_recordwithop="create"anditems, up to 50 records per call. Create takes snake_case field names (first_name,job_title,company_id), while update takes camelCase infields(jobTitle). An unrecognized create field is refused rather than dropped, so don't mix the two vocabularies. Run the first batch withdry_run=trueto see what would be written.- A bulk call returns a receipt with per-item failures. Retry only the failed items, never the whole batch, or every success is created twice.
- Existing people:
op="update"withitems, one{"id", "fields"}per record. Only the fields you send change, but a list field you send is replaced whole. To link the account, read the person's currentcompaniesand send the full list back in camelCase,{"companies": [{"id": "<a company already linked>"}, {"id": "<the account id>"}]}: sending the account alone unlinks every other company the person had. - Read back each written person's
companies, created and updated alike. Folk links a company matched on the email's domain on its own, even whencompany_idis passed, and may create one for it: a duplicate of the account you meant. When the read-back shows a company you didn't send, sendcompaniesagain without it and name both companies in the report, so a person can merge or delete the duplicate. This process never deletes a company itself. op="add_to_group"withids(up to 50) and the segment's group, so every contact carries the segment its account came from and the roster stays traceable to why it exists. Existing group memberships are preserved.
7. Check the campaign holds new leads
lemlist_campaignwithop="reports"andcampaign_ids=[<your outreach campaign id>]— readtotalCount,reviewedCountandinSequenceLeadCountbefore adding anyone. Together they show the review lock without sending anything: a lead held for review raises onlytotalCount, while a lead the campaign launches as it is added raises the other two as well. Neither the campaign record nor a lead'sisPausedtells the two apart.- Whether an added lead waits is the campaign's setting, not this process's. A lead is held only while the campaign reviews leads before sending. With auto-review on, every lead added sends on creation, and this process adds leads to a campaign you supply, which may already have it on.
lemlist_create_lead— one probe lead first, on [an internal address you own], never on a prospect, withdeduplicate=falseso an address already used elsewhere isn't skipped (delete any probe an earlier run left behind first). Then readop="reports"again. Trust the reading only oncetotalCounthas risen by one; until it has, read again rather than take unchanged counts as a pass.- If
reviewedCountorinSequenceLeadCountrose with it, the campaign auto-reviews: add nobody, and put it first in the report with the campaign named. Your own address is what makes the probe safe: the one message that may leave lands in your inbox, not a prospect's. lemlist_leadwithop="delete"on the probe, whatever the answer, so it never stays in the campaign as a lead.- Probe on every run, not once: a campaign's settings can change between runs without notice. A campaign that auto-reviews stays that way until its owner changes it in Lemlist; this process never switches
lemlist_campaign_auto_review, in either direction.
8. Queue each contact for outreach
lemlist_get_leads— read the leads of [your outreach campaign] once at the start of the step and build the set of addresses already in it, so the check happens before adding, not after. This read includes leads in every state; a listing left on the default state filter comes back empty and reads as "no leads".lemlist_create_lead— every resolved contact not already in the set, new or updated, with at leastemail,first_name,last_name,company_nameandjob_title, the role and the signal incustom_variables, anddeduplicate=trueso an address already live in another campaign is skipped as well.- The lead lands held for review, because step 7 showed on this run that the campaign holds new leads. Creating it sends nothing: a person reads the queued leads in Lemlist and releases them. Only a process that explicitly owns sending calls
lemlist_launch_lead, once per lead id, which is the actual send and is hidden by default; this one hands that decision to a person. lemlist_campaignwithop="reports"after every [chunk of leads] and once the batch is in —totalCountshould rise by the number of leads added and the other two not at all. A rise in either of the last two means the campaign changed during the run: stop adding, and put it first in the report with the leads added since the last clean reading, since those may have sent.- Never turn on
lemlist_campaign_auto_review. With it, every lead this step adds sends on creation, and a contact found this morning gets an email nobody read.
Output
Report, with anything about the campaign first: whether the probe showed the campaign holding new leads for review, or auto-reviewing so that nobody was added; then accounts in the segment and how many had nobody linked, which accounts had no usable domain, candidates found per role, which accounts came up empty for which role, split into not found and not found within budget, emails found, not found and held for a domain mismatch, contacts created versus updated in the CRM, any duplicate company Folk linked and was unlinked from, leads added and held for review versus launched, and leads skipped as already in a campaign.