Finds people by job title at the companies you list, confirms a work address for each, records them, then sends a first email.

UsesClaudeApolloGmailGoogle SheetsHunter

Find people at the companies you name and send a first email

Run it by hand whenever you want to reach new people and it does the whole first touch: finds people with the job titles you want at the companies you list, works out a work address for each, checks it is real, writes a short first email and sends it from your Gmail. Only people at companies you named yourself are contacted.

Everyone contacted is recorded on a tab in your Google Sheet, created on the first run and read before every run so nobody is emailed twice. No address is guessed: only one confirmed by a mailbox check, with a confident score and at the company's own domain, is written to. The AI writes each email from the person's name, job title and company alone, a draft that implies you have already spoken is discarded, and your sign-off and opt-out line are added to every message. Each send is recorded before it is made, so a failure leaves a recorded row rather than a stranger emailed twice. There is no schedule on purpose: cold outreach should not run unattended.

ValuesSet sheetUrl to blank.Set sheetUrl to blank.
ValuesSet sentLogTabName to blank.Set sentLogTabName to blank.
ValuesSet companyDomains to blank.Set companyDomains to blank.
ValuesSet jobTitles to blank.Set jobTitles to blank.
ValuesSet whatYouOffer to blank.Set whatYouOffer to blank.
ValuesSet senderSignOff to blank.Set senderSignOff to blank.
ValuesSet optOutLine to blank.Set optOutLine to blank.
ValuesSet maxEmailsThisRun to 1.Set maxEmailsThisRun to 1.
MathSet sendLimit to maxEmailsThisRun multiplied by 1.Set sendLimit to maxEmailsThisRun multiplied by 1.
Decisions & LoopsIf sendLimit is less than 1 or sendLimit is greater than 200If sendLimit is less than 1 or sendLimit is greater than 200
Yes
Decisions & LoopsStop and report a problem: 'maxEmailsThisRun' must be a whole number between 1 and 200. Set it in the 'maxEmailsThisRun' step and run it again.Stop and report a problem: 'maxEmailsThisRun' must be a whole number between 1 and 200. Set it in the 'maxEmailsThisRun' step and run it again.
No
No steps
ValuesSet domainList to an empty list.Set domainList to an empty list.
Decisions & LoopsFor each rawDomain in companyDomains split by ,:For each rawDomain in companyDomains split by ,:
TextMake rawDomain lowercase and save it as cleanedDomain.Make rawDomain lowercase and save it as cleanedDomain.
Decisions & LoopsIf cleanedDomain starts with https:// (ignoring case)If cleanedDomain starts with https:// (ignoring case)
Yes
ListsGet the last item of cleanedDomain split by https:// and save it as cleanedDomain.Get the last item of cleanedDomain split by https:// and save it as cleanedDomain.
No
No steps
Decisions & LoopsIf cleanedDomain starts with http:// (ignoring case)If cleanedDomain starts with http:// (ignoring case)
Yes
ListsGet the last item of cleanedDomain split by http:// and save it as cleanedDomain.Get the last item of cleanedDomain split by http:// and save it as cleanedDomain.
No
No steps
Decisions & LoopsIf cleanedDomain starts with www. (ignoring case)If cleanedDomain starts with www. (ignoring case)
Yes
ListsGet the last item of cleanedDomain split by www. and save it as cleanedDomain.Get the last item of cleanedDomain split by www. and save it as cleanedDomain.
No
No steps
ListsGet the first item of cleanedDomain split by / and save it as cleanedDomain.Get the first item of cleanedDomain split by / and save it as cleanedDomain.
Decisions & LoopsIf cleanedDomain contains . (ignoring case) and the length of cleanedDomain is…If cleanedDomain contains . (ignoring case) and the length of cleanedDomain is…
Yes
ListsAdd cleanedDomain to the end of domainList and save it as domainList.Add cleanedDomain to the end of domainList and save it as domainList.
No
ValuesShow in the output: Ignored '@rawDomain' in companyDomains - it is not a website domain.Show in the output: Ignored '@rawDomain' in companyDomains - it is not a website domain.
Decisions & LoopsIf domainList is emptyIf domainList is empty
Yes
Decisions & LoopsStop and report a problem: None of the entries in 'companyDomains' looked like a website domain. Write them as 'acme.com, northwind.com' and run it again.Stop and report a problem: None of the entries in 'companyDomains' looked like a website domain. Write them as 'acme.com, northwind.com' and run it again.
No
No steps
ValuesSet titleList to an empty list.Set titleList to an empty list.
Decisions & LoopsFor each rawTitle in jobTitles split by ,:For each rawTitle in jobTitles split by ,:
TextTrim rawTitle and save it as cleanedTitle.Trim rawTitle and save it as cleanedTitle.
Decisions & LoopsIf cleanedTitle isn't emptyIf cleanedTitle isn't empty
Yes
ListsAdd cleanedTitle to the end of titleList and save it as titleList.Add cleanedTitle to the end of titleList and save it as titleList.
No
No steps
Decisions & LoopsIf titleList is emptyIf titleList is empty
Yes
Decisions & LoopsStop and report a problem: 'jobTitles' did not contain any titles. Write them as 'Head of Operations, COO' and run it again.Stop and report a problem: 'jobTitles' did not contain any titles. Write them as 'Head of Operations, COO' and run it again.
No
No steps
ValuesSet sentLogIsNew to true.Set sentLogIsNew to true.
Decisions & LoopsIf a step below fails, save the error as createLogTabErrorIf a step below fails, save the error as createLogTabError
Try
Google SheetsGoogle SheetsCreate the tab sentLogTabName in sheetUrl.Create the tab sentLogTabName in sheetUrl.
On error
ValuesChange sentLogIsNew to false.Change sentLogIsNew to false.
Decisions & LoopsIf sentLogIsNew is yesIf sentLogIsNew is yes
Yes
Google SheetsGoogle SheetsAdd a row to sentLogTabName in sheetUrl.Add a row to sentLogTabName in sheetUrl.
No
No steps
Google SheetsGoogle SheetsRead column 'A' of sentLogTabName in sheetUrl. Save it as sentLog.Read column 'A' of sentLogTabName in sheetUrl. Save it as sentLog.
ValuesSet alreadySent to an empty list.Set alreadySent to an empty list.
Decisions & LoopsFor each loggedAddress in sentLog.values:For each loggedAddress in sentLog.values:
Decisions & LoopsIf a step below fails, save the error as loggedAddressErrorIf a step below fails, save the error as loggedAddressError
Try
ListsAdd loggedAddress in lowercase to the end of alreadySent and save it as alreadySent.Add loggedAddress in lowercase to the end of alreadySent and save it as alreadySent.
On error
ValuesShow in the output: Ignored a cell in the '@sentLogTabName' tab that is not text.Show in the output: Ignored a cell in the '@sentLogTabName' tab that is not text.
ApolloApolloSearch people for titleList. Save it as searchResult.Search people for titleList. Save it as searchResult.
ValuesSet sentCount to 0.Set sentCount to 0.
ValuesSet skippedRows to 0.Set skippedRows to 0.
ValuesSet sendFailures to 0.Set sendFailures to 0.
Decisions & LoopsFor each person in searchResult.people:For each person in searchResult.people:
Decisions & LoopsIf sentCount is greater than or equal to sendLimitIf sentCount is greater than or equal to sendLimit
Yes
Decisions & LoopsStop repeating.Stop repeating.
No
No steps
ValuesSet fullName to blank.Set fullName to blank.
Decisions & LoopsIf a step below fails, save the error as nameReadErrorIf a step below fails, save the error as nameReadError
Try
TextTrim person.name and save it as fullName.Trim person.name and save it as fullName.
On error
ValuesChange fullName to blank.Change fullName to blank.
Decisions & LoopsIf fullName is empty or fullName is nullIf fullName is empty or fullName is null
Yes
ValuesShow in the output: Skipped a search result that had no name on it..Show in the output: Skipped a search result that had no name on it..
ValuesAdd 1 to skippedRows.Add 1 to skippedRows.
Decisions & LoopsSkip to the next item.Skip to the next item.
No
No steps
ValuesSet jobTitle to blank.Set jobTitle to blank.
Decisions & LoopsIf a step below fails, save the error as titleReadErrorIf a step below fails, save the error as titleReadError
Try
TextTrim person.title and save it as jobTitle.Trim person.title and save it as jobTitle.
On error
ValuesChange jobTitle to blank.Change jobTitle to blank.
ValuesSet employer to person.organization.Set employer to person.organization.
ValuesSet companyName to blank.Set companyName to blank.
Decisions & LoopsIf a step below fails, save the error as companyReadErrorIf a step below fails, save the error as companyReadError
Try
TextTrim employer.name and save it as companyName.Trim employer.name and save it as companyName.
On error
ValuesChange companyName to blank.Change companyName to blank.
ValuesSet companyDomain to blank.Set companyDomain to blank.
Decisions & LoopsIf a step below fails, save the error as domainReadErrorIf a step below fails, save the error as domainReadError
Try
TextMake employer.primary_domain lowercase and save it as companyDomain.Make employer.primary_domain lowercase and save it as companyDomain.
On error
ValuesChange companyDomain to blank.Change companyDomain to blank.
Decisions & LoopsIf domainList doesn't contain companyDomainIf domainList doesn't contain companyDomain
Yes
ValuesShow in the output: Skipped fullName: the search returned them at '@companyDomain', which is not one of the domains you listed.Show in the output: Skipped fullName: the search returned them at '@companyDomain', which is not one of the domains you listed.
ValuesAdd 1 to skippedRows.Add 1 to skippedRows.
Decisions & LoopsSkip to the next item.Skip to the next item.
No
No steps
HunterHunterFind companyDomain fullNames email at. Save it as finderResult.Find companyDomain fullNames email at. Save it as finderResult.
ValuesSet candidateAddress to blank.Set candidateAddress to blank.
Decisions & LoopsIf a step below fails, save the error as finderReadErrorIf a step below fails, save the error as finderReadError
Try
TextMake finderResult.email lowercase and save it as candidateAddress.Make finderResult.email lowercase and save it as candidateAddress.
On error
ValuesChange candidateAddress to blank.Change candidateAddress to blank.
Decisions & LoopsIf candidateAddress is empty or candidateAddress is nullIf candidateAddress is empty or candidateAddress is null
Yes
ValuesShow in the output: No address was found for fullName at companyDomain, so nothing was sent.Show in the output: No address was found for fullName at companyDomain, so nothing was sent.
ValuesAdd 1 to skippedRows.Add 1 to skippedRows.
Decisions & LoopsSkip to the next item.Skip to the next item.
No
No steps
Decisions & LoopsIf not candidateAddress is a valid email addressIf not candidateAddress is a valid email address
Yes
ValuesShow in the output: Skipped fullName: the address that came back was not a valid email address.Show in the output: Skipped fullName: the address that came back was not a valid email address.
ValuesAdd 1 to skippedRows.Add 1 to skippedRows.
Decisions & LoopsSkip to the next item.Skip to the next item.
No
No steps
Decisions & LoopsIf candidateAddress doesn't end with @@companyDomain (ignoring case)If candidateAddress doesn't end with @@companyDomain (ignoring case)
Yes
ValuesShow in the output: Skipped fullName: the address found (candidateAddress) is not at companyDomain.Show in the output: Skipped fullName: the address found (candidateAddress) is not at companyDomain.
ValuesAdd 1 to skippedRows.Add 1 to skippedRows.
Decisions & LoopsSkip to the next item.Skip to the next item.
No
No steps
HunterHunterVerify the email candidateAddress. Save it as verification.Verify the email candidateAddress. Save it as verification.
ValuesSet verifyStatus to blank.Set verifyStatus to blank.
Decisions & LoopsIf a step below fails, save the error as verifyReadErrorIf a step below fails, save the error as verifyReadError
Try
TextMake verification.status lowercase and save it as verifyStatus.Make verification.status lowercase and save it as verifyStatus.
On error
ValuesChange verifyStatus to blank.Change verifyStatus to blank.
Decisions & LoopsIf verifyStatus is not validIf verifyStatus is not valid
Yes
ValuesShow in the output: Not sending to candidateAddress: the mailbox check came back '@verifyStatus' rather than 'valid', so the address is not confirmed.Show in the output: Not sending to candidateAddress: the mailbox check came back '@verifyStatus' rather than 'valid', so the address is not confirmed.
ValuesAdd 1 to skippedRows.Add 1 to skippedRows.
Decisions & LoopsSkip to the next item.Skip to the next item.
No
No steps
MathSet verifyScore to verification.score multiplied by 1.Set verifyScore to verification.score multiplied by 1.
Decisions & LoopsIf verifyScore is less than 70 or verifyScore is greater than 100If verifyScore is less than 70 or verifyScore is greater than 100
Yes
ValuesShow in the output: Not sending to candidateAddress: the deliverability score was not a confident one.Show in the output: Not sending to candidateAddress: the deliverability score was not a confident one.
ValuesAdd 1 to skippedRows.Add 1 to skippedRows.
Decisions & LoopsSkip to the next item.Skip to the next item.
No
No steps
Decisions & LoopsIf alreadySent contains candidateAddressIf alreadySent contains candidateAddress
Yes
ValuesShow in the output: Skipped candidateAddress: already on your record, so they are not contacted twice.Show in the output: Skipped candidateAddress: already on your record, so they are not contacted twice.
ValuesAdd 1 to skippedRows.Add 1 to skippedRows.
Decisions & LoopsSkip to the next item.Skip to the next item.
No
No steps
ValuesSet promptFacts to THIS PERSONSet promptFacts to THIS PERSON Name: fullName Job title: jobTitle Company: companyName Company website: companyDomain.
ValuesSet promptOffer to WHY I AM WRITING TO THEM, in my own words:Set promptOffer to WHY I AM WRITING TO THEM, in my own words: whatYouOffer.
ValuesSet prompt to You are writing one short first-contact email to a person who has never heard from me before.Set prompt to You are writing one short first-contact email to a person who has never heard from me before. Rules you must follow exactly: - Use only the facts listed under THIS PERSON below. Everything there came from a contact database, and nothing else about them is known. Do not add their recent news, their funding, their headcount, their tools or their background. If a detail is not listed below, it does not go in the email. - Never suggest we have met, spoken, emailed, been introduced, or that they asked to hear from me. There has been no contact of any kind. - Never mention or imply anyone's age, gender, race, nationality, religion, health, disability, politics or family. - Write the way one person writes to another: 110 words at most, plain sentences, one clear ask, no bullet points, no marketing language, no flattery you cannot support. - Do not write a sign-off, a signature or an opt-out line. Those are added afterwards. Reply in exactly this shape and nothing else: Subject: <a specific subject line, under 70 characters> <blank line> <the body of the email> promptFacts promptOffer.
ClaudeClaudeAsk Claude: prompt. Save it as drafted.Ask Claude: prompt. Save it as drafted. Maximum response length: 700
ValuesSet draftText to blank.Set draftText to blank.
Decisions & LoopsIf a step below fails, save the error as draftReadErrorIf a step below fails, save the error as draftReadError
Try
TextTrim drafted.response_text and save it as draftText.Trim drafted.response_text and save it as draftText.
On error
ValuesChange draftText to blank.Change draftText to blank.
Decisions & LoopsIf draftText is empty or draftText is nullIf draftText is empty or draftText is null
Yes
ValuesShow in the output: Skipped candidateAddress: the model returned no draft.Show in the output: Skipped candidateAddress: the model returned no draft.
ValuesAdd 1 to skippedRows.Add 1 to skippedRows.
Decisions & LoopsSkip to the next item.Skip to the next item.
No
No steps
TextSplit draftText by a new line and save it as draftLines.Split draftText by a new line and save it as draftLines.
TextTrim the first item of draftLines with Subject: replaced by (ignoring case) and save it as emailSubject.Trim the first item of draftLines with Subject: replaced by (ignoring case) and save it as emailSubject.
TextTrim the items from index 1 onward of draftLines joined by a new line and save it as emailBodyText.Trim the items from index 1 onward of draftLines joined by a new line and save it as emailBodyText.
Decisions & LoopsIf emailSubject is empty or the length of emailSubject is greater than 120 or the…If emailSubject is empty or the length of emailSubject is greater than 120 or the…
Yes
ValuesShow in the output: Skipped candidateAddress: the draft did not come back as a subject line and a body.Show in the output: Skipped candidateAddress: the draft did not come back as a subject line and a body.
ValuesAdd 1 to skippedRows.Add 1 to skippedRows.
Decisions & LoopsSkip to the next item.Skip to the next item.
No
No steps
ValuesSet wholeDraft to emailSubjectSet wholeDraft to emailSubject emailBodyText.
ValuesSet claimsGroup1 to wholeDraft contains as we discussed (ignoring case) or wholeDraft contains as discussed (ignoring case) or wholeDraft contains following up on our call (ignoring case) or wholeDraft contains as promised (ignoring case) or wholeDraft contains per our conversation (ignoring case).Set claimsGroup1 to wholeDraft contains as we discussed (ignoring case) or wholeDraft contains as discussed (ignoring case) or wholeDraft contains following up on our call (ignoring case) or wholeDraft contains as promised (ignoring case) or wholeDraft contains per our conversation (ignoring case).
ValuesSet claimsGroup2 to wholeDraft contains great speaking (ignoring case) or wholeDraft contains we spoke (ignoring case) or wholeDraft contains our meeting (ignoring case) or wholeDraft contains thanks for your time (ignoring case) or wholeDraft contains since we last (ignoring case).Set claimsGroup2 to wholeDraft contains great speaking (ignoring case) or wholeDraft contains we spoke (ignoring case) or wholeDraft contains our meeting (ignoring case) or wholeDraft contains thanks for your time (ignoring case) or wholeDraft contains since we last (ignoring case).
ValuesSet claimsGroup3 to wholeDraft contains you asked me to (ignoring case) or wholeDraft contains you requested (ignoring case) or wholeDraft contains circling back (ignoring case) or wholeDraft contains as agreed (ignoring case).Set claimsGroup3 to wholeDraft contains you asked me to (ignoring case) or wholeDraft contains you requested (ignoring case) or wholeDraft contains circling back (ignoring case) or wholeDraft contains as agreed (ignoring case).
Decisions & LoopsIf claimsGroup1 is yes or claimsGroup2 is yes or claimsGroup3 is yesIf claimsGroup1 is yes or claimsGroup2 is yes or claimsGroup3 is yes
Yes
ValuesShow in the output: Skipped candidateAddress: the draft implied you had already been in touch, which is not true of a first email, so nothing was sent.Show in the output: Skipped candidateAddress: the draft implied you had already been in touch, which is not true of a first email, so nothing was sent.
ValuesAdd 1 to skippedRows.Add 1 to skippedRows.
Decisions & LoopsSkip to the next item.Skip to the next item.
No
No steps
ValuesSet emailBody to emailBodyTextSet emailBody to emailBodyText senderSignOff optOutLine.
Google SheetsGoogle SheetsAdd a row to sentLogTabName in sheetUrl.Add a row to sentLogTabName in sheetUrl.
ListsAdd candidateAddress to the end of alreadySent and save it as alreadySent.Add candidateAddress to the end of alreadySent and save it as alreadySent.
Decisions & LoopsIf a step below fails, save the error as sendErrorIf a step below fails, save the error as sendError
Try
GmailGmailSend an email to candidateAddress.Send an email to candidateAddress. Subject: emailSubjectemailBody
On error
ValuesAdd 1 to sendFailures.Add 1 to sendFailures.
ValuesShow in the output: Could not send to candidateAddress. They are on the sent log already, so this run will not try them again: sendError as text.Show in the output: Could not send to candidateAddress. They are on the sent log already, so this run will not try them again: sendError as text.
ValuesAdd 1 to sentCount.Add 1 to sentCount.
ValuesShow in the output: Contacted sentCount as text new people. Passed over skippedRows as text search results.Show in the output: Contacted sentCount as text new people. Passed over skippedRows as text search results.
Decisions & LoopsIf sendFailures is greater than 0If sendFailures is greater than 0
Yes
Decisions & LoopsStop and report a problem: sendFailures as text email(s) could not be sent. Every one of them is already on your sent log, so they will not be retried - check your Gmail connection and reach them by hand if you still want to.Stop and report a problem: sendFailures as text email(s) could not be sent. Every one of them is already on your sent log, so they will not be retried - check your Gmail connection and reach them by hand if you still want to.
No
No steps