Reads a page each prospect has published and opens their email on a line checked back against that page, or on nothing at all.
Uses
Claude
Gmail
Google Sheets
Jina AI
Open a cold email on something they actually published
Give it a sheet of prospects, each with a link to something they have published, and run it by hand. Each page is read, the AI writes an opening line about something that page actually says and a short first email around it, and the email goes out from Gmail with your sign-off and opt-out line added.
Every opening line has to come with the passage of the page it rests on, and that passage is matched back against the words on the page. If it is not there, or the line uses a figure the page never mentions, the email opens on a plain sentence that claims nothing. A draft that implies you have spoken before, or states a figure found neither on the page nor in your own words, is discarded rather than sent. Each send is recorded on a sent log before it goes out and every address is checked against that log, so nobody is emailed twice. If Gmail refuses an email, that person is taken back off the log and the run stops, saying why.
You need Google Sheets, Jina, Anthropic and Gmail connected, a prospect tab with 'Email', 'Name', 'Company' and 'Page URL' columns, and your own words for what you offer, your sign-off and your opt-out line. Sending commercial email, and the consent behind it, remains your responsibility.
sheetUrl to blank.Set sheetUrl to blank.prospectTabName to blank.Set prospectTabName to blank.sentLogTabName to Sent log.Set sentLogTabName to Sent log.whatYouOffer to blank.Set whatYouOffer to blank.senderSignOff to blank.Set senderSignOff to blank.optOutLine to blank.Set optOutLine to blank.maxEmailsThisRun to 1.Set maxEmailsThisRun to 1.sentLogTabName is blankIf sentLogTabName is blanksentLogTabName to Sent log.Change sentLogTabName to Sent log.sendLimit to maxEmailsThisRun multiplied by 1.Set sendLimit to maxEmailsThisRun multiplied by 1.sendLimit is less than 1 or sendLimit is greater than 200If sendLimit is less than 1 or sendLimit is greater than 200sentLogIsNew to true.Set sentLogIsNew to true.createLogTabErrorIf a step below fails, save the error as createLogTabErrorsentLogTabName in sheetUrl.Create the tab sentLogTabName in sheetUrl.sentLogIsNew to false.Change sentLogIsNew to false.sentLogIsNew is yesIf sentLogIsNew is yessentLogTabName in sheetUrl.Add a row to sentLogTabName in sheetUrl.sentLogTabName in sheetUrl. Save it as sentLog.Read column 'A' of sentLogTabName in sheetUrl. Save it as sentLog.alreadySent to an empty list.Set alreadySent to an empty list.loggedAddress in sentLog.values:For each loggedAddress in sentLog.values:loggedAddressErrorIf a step below fails, save the error as loggedAddressErrorloggedAddress 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.sheetUrl. Save it as prospectSheet.Read the rows of sheetUrl. Save it as prospectSheet.sentCount to 0.Set sentCount to 0.skippedRows to 0.Set skippedRows to 0.stoppedAtLimit to false.Set stoppedAtLimit to false.row in prospectSheet.rows:For each row in prospectSheet.rows:sentCount is greater than or equal to sendLimitIf sentCount is greater than or equal to sendLimitstoppedAtLimit to true.Change stoppedAtLimit to true.rowLabel to row row.row_number as text.Set rowLabel to row row.row_number as text.recipient to blank.Set recipient to blank.recipientReadErrorIf a step below fails, save the error as recipientReadErrorrow.Email and save it as recipient.Trim row.Email and save it as recipient.recipient to blank.Change recipient to blank.recipient is emptyIf recipient is emptyrowLabel: the Email column is empty.Show in the output: Skipped rowLabel: the Email column is empty.skippedRows.Add 1 to skippedRows.recipient is a valid email addressIf not recipient is a valid email addressrowLabel: '@recipient' is not a valid email address, so nothing was sent to it.Show in the output: Skipped rowLabel: '@recipient' is not a valid email address, so nothing was sent to it.skippedRows.Add 1 to skippedRows.recipient lowercase and save it as recipientKey.Make recipient lowercase and save it as recipientKey.alreadySent contains recipientKeyIf alreadySent contains recipientKeyrecipient: already on the sent log, so they are not emailed twice.Show in the output: Skipped recipient: already on the sent log, so they are not emailed twice.skippedRows.Add 1 to skippedRows.contactName to blank.Set contactName to blank.contactNameReadErrorIf a step below fails, save the error as contactNameReadErrorrow.Name and save it as contactName.Trim row.Name and save it as contactName.contactName to blank.Change contactName to blank.companyName to blank.Set companyName to blank.companyNameReadErrorIf a step below fails, save the error as companyNameReadErrorrow.Company and save it as companyName.Trim row.Company and save it as companyName.companyName to blank.Change companyName to blank.pageUrl to blank.Set pageUrl to blank.pageUrlReadErrorIf a step below fails, save the error as pageUrlReadErrorrow["Page URL"] and save it as pageUrl.Trim row["Page URL"] and save it as pageUrl.pageUrl to blank.Change pageUrl to blank.companyName is emptyIf companyName is emptyrecipient: the Company column is empty.Show in the output: Skipped recipient: the Company column is empty.skippedRows.Add 1 to skippedRows.pageUrl is empty or pageUrl is null or pageUrl doesn't start with…If pageUrl is empty or pageUrl is null or pageUrl doesn't start with…recipient: the Page URL column does not hold an https:// web address, and this template has nothing to open on without one.Show in the output: Skipped recipient: the Page URL column does not hold an https:// web address, and this template has nothing to open on without one.skippedRows.Add 1 to skippedRows.pageText to blank.Set pageText to blank.pageReadErrorIf a step below fails, save the error as pageReadErrorpageText.Trim the content from the Jina AI “Read a URL” step and save it as pageText.pageUrl for recipient.Show in the output: Could not read pageUrl for recipient.pageText is nullIf pageText is nullpageText to blank.Change pageText to blank.openingLine to blank.Set openingLine to blank.openerEvidence to blank.Set openerEvidence to blank.openerSource to generic.Set openerSource to generic.pageText is greater than or equal to 400If the length of pageText is greater than or equal to 400pageText to 6000 characters and save it as pageExcerpt.Shorten pageText to 6000 characters and save it as pageExcerpt.openerPrompt to You are reading one page a person has published, and writing the first sentence of a cold email to them.Set openerPrompt to You are reading one page a person has published, and writing the first sentence of a cold email to them.
Everything you write must be supported by the PAGE TEXT below and nothing else. Do not use anything you already know about this person or this company. Do not guess, estimate or round any number. Do not describe anything the page does not state. Do not mention or imply anyone's age, gender, race, nationality, religion, health, disability, politics or family.
Reply in exactly this shape and nothing else:
EVIDENCE: <one span of at least 25 characters, copied word for word out of the page text, that the opening sentence rests on>
OPENER: <one sentence, under 240 characters, saying only what that span says>
If the page gives you nothing specific worth opening on, reply with the single word NOTHING and no other line.
THE PERSON
Name: contactName
Company: companyName
Page: pageUrl
PAGE TEXT
pageExcerpt.openerPrompt. Save it as openerReply.Ask Claude: openerPrompt. Save it as openerReply.
Maximum response length: 400openerText to blank.Set openerText to blank.openerReadErrorIf a step below fails, save the error as openerReadErroropenerReply.response_text and save it as openerText.Trim openerReply.response_text and save it as openerText.openerText to blank.Change openerText to blank.replyLine in openerText split by a new line:For each replyLine in openerText split by a new line:replyLine and save it as trimmedLine. Trim replyLine and save it as trimmedLine.trimmedLine starts with EVIDENCE: (ignoring case) If trimmedLine starts with EVIDENCE: (ignoring case)trimmedLine with EVIDENCE: replaced by (ignoring case) and save it as openerEvidence.Trim trimmedLine with EVIDENCE: replaced by (ignoring case) and save it as openerEvidence.trimmedLine starts with OPENER: (ignoring case) If trimmedLine starts with OPENER: (ignoring case)trimmedLine with OPENER: replaced by (ignoring case) and save it as openingLine.Trim trimmedLine with OPENER: replaced by (ignoring case) and save it as openingLine.\]\([^)]*\) with in pageExcerpt and save it as pageWithoutLinkTargets.Replace the pattern \]\([^)]*\) with in pageExcerpt and save it as pageWithoutLinkTargets.[\W_]+ with in pageWithoutLinkTargets and save it as pageWords.Replace the pattern [\W_]+ with in pageWithoutLinkTargets and save it as pageWords.\]\([^)]*\) with in openerEvidence and save it as evidenceWithoutLinkTargets.Replace the pattern \]\([^)]*\) with in openerEvidence and save it as evidenceWithoutLinkTargets.[\W_]+ with in evidenceWithoutLinkTargets and save it as evidenceSpaced.Replace the pattern [\W_]+ with in evidenceWithoutLinkTargets and save it as evidenceSpaced.evidenceSpaced and save it as evidenceWords.Trim evidenceSpaced and save it as evidenceWords.evidenceIsReal to the length of evidenceWords is greater than or equal to 20 and pageWords contains evidenceWords (ignoring case).Set evidenceIsReal to the length of evidenceWords is greater than or equal to 20 and pageWords contains evidenceWords (ignoring case).openingLine and save it as openerFigures.Extract all matches from openingLine and save it as openerFigures.figuresAreInThePage to true.Set figuresAreInThePage to true.figure in openerFigures:For each figure in openerFigures:pageText doesn't contain figure (ignoring case) If pageText doesn't contain figure (ignoring case)figuresAreInThePage to false.Change figuresAreInThePage to false.openerLooksLikeASentence to the length of openingLine is greater than or equal to 20 and the length of openingLine is less than or equal to 260.Set openerLooksLikeASentence to the length of openingLine is greater than or equal to 20 and the length of openingLine is less than or equal to 260.openerClaimsGroup1 to openingLine contains as we discussed (ignoring case) or openingLine contains as discussed (ignoring case) or openingLine contains following up on our call (ignoring case) or openingLine contains as promised (ignoring case) or openingLine contains per our conversation (ignoring case).Set openerClaimsGroup1 to openingLine contains as we discussed (ignoring case) or openingLine contains as discussed (ignoring case) or openingLine contains following up on our call (ignoring case) or openingLine contains as promised (ignoring case) or openingLine contains per our conversation (ignoring case).openerClaimsGroup2 to openingLine contains great speaking (ignoring case) or openingLine contains we spoke (ignoring case) or openingLine contains our meeting (ignoring case) or openingLine contains thanks for your time (ignoring case) or openingLine contains since we last (ignoring case).Set openerClaimsGroup2 to openingLine contains great speaking (ignoring case) or openingLine contains we spoke (ignoring case) or openingLine contains our meeting (ignoring case) or openingLine contains thanks for your time (ignoring case) or openingLine contains since we last (ignoring case).openerClaimsGroup3 to openingLine contains you asked me to (ignoring case) or openingLine contains you requested (ignoring case) or openingLine contains circling back (ignoring case) or openingLine contains as agreed (ignoring case).Set openerClaimsGroup3 to openingLine contains you asked me to (ignoring case) or openingLine contains you requested (ignoring case) or openingLine contains circling back (ignoring case) or openingLine contains as agreed (ignoring case).evidenceIsReal is yes and figuresAreInThePage is yes and…If evidenceIsReal is yes and figuresAreInThePage is yes and…openerSource to from the page.Change openerSource to from the page.pageUrl could be checked against the page, so recipient gets a plain opening line instead of an invented one.Show in the output: Nothing on pageUrl could be checked against the page, so recipient gets a plain opening line instead of an invented one.openingLine to blank.Change openingLine to blank.openerEvidence to blank.Change openerEvidence to blank.pageUrl to open on, so recipient gets a plain opening line.Show in the output: There was not enough text on pageUrl to open on, so recipient gets a plain opening line.openingLine is emptyIf openingLine is emptyopenerSource to generic.Change openerSource to generic.openingLine to I am writing to a few people at companyName and thought you might be the right person to ask.Change openingLine to I am writing to a few people at companyName and thought you might be the right person to ask.openerEvidence to nothing on the page could be checked, so no claim was made.Change openerEvidence to nothing on the page could be checked, so no claim was made.emailPrompt to "Write one short first-contact email.Set emailPrompt to Write one short first-contact email.
The body must begin with this sentence, word for word and unchanged:
{opening}
After that sentence, say only what is written under WHY I AM WRITING below. Do not add any other fact, figure, date or claim about the recipient or their company - not one you know from elsewhere, and not one you infer. Never suggest we have met, spoken, emailed or been introduced.
Do not use digits anywhere in the email. If you need a number, write it as a word - 'fifteen minutes', not '15 minutes'.
100 words at most after the opening sentence. No sign-off, no signature and no 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, starting with the opening sentence above> with {opening} replaced by openingLine
THE PERSON
Name: contactName
Company: companyName
WHY I AM WRITING, in my own words:
whatYouOffer.emailPrompt. Save it as drafted.Ask Claude: emailPrompt. Save it as drafted.
Maximum response length: 700draftText to blank.Set draftText to blank.draftReadErrorIf a step below fails, save the error as draftReadErrordrafted.response_text and save it as draftText.Trim drafted.response_text and save it as draftText.draftText to blank.Change draftText to blank.draftText is empty or draftText is nullIf draftText is empty or draftText is nullrecipient: the model returned no draft.Show in the output: Skipped recipient: the model returned no draft.skippedRows.Add 1 to skippedRows.draftText by a new line and save it as draftLines.Split draftText by a new line and save it as draftLines.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.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.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…recipient: the draft did not come back as a subject line and a body.Show in the output: Skipped recipient: the draft did not come back as a subject line and a body.skippedRows.Add 1 to skippedRows.emailBodyText doesn't contain openingLineIf emailBodyText doesn't contain openingLinerecipient: the draft rewrote the opening line that had been checked against the page, so it was thrown away.Show in the output: Skipped recipient: the draft rewrote the opening line that had been checked against the page, so it was thrown away.skippedRows.Add 1 to skippedRows.wholeDraft to emailSubjectSet wholeDraft to emailSubject
emailBodyText.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).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).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).claimsGroup1 is yes or claimsGroup2 is yes or claimsGroup3 is yesIf claimsGroup1 is yes or claimsGroup2 is yes or claimsGroup3 is yesrecipient: 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 recipient: the draft implied you had already been in touch, which is not true of a first email, so nothing was sent.skippedRows.Add 1 to skippedRows.sourceMaterial to pageTextSet sourceMaterial to pageText
whatYouOffer
companyName
contactName
openingLine.wholeDraft and save it as draftFigures.Extract all matches from wholeDraft and save it as draftFigures.draftIsTraceable to true.Set draftIsTraceable to true.draftFigure in draftFigures:For each draftFigure in draftFigures:sourceMaterial doesn't contain draftFigure (ignoring case)If sourceMaterial doesn't contain draftFigure (ignoring case)draftIsTraceable to false.Change draftIsTraceable to false.draftIsTraceable is noIf draftIsTraceable is norecipient: the draft stated a figure that is not on the page it read or in your own words, so it was thrown away rather than sent.Show in the output: Skipped recipient: the draft stated a figure that is not on the page it read or in your own words, so it was thrown away rather than sent.skippedRows.Add 1 to skippedRows.emailBody to emailBodyTextSet emailBody to emailBodyText
senderSignOff
optOutLine.sentLogTabName in sheetUrl.Add a row to sentLogTabName in sheetUrl.recipientKey to the end of alreadySent and save it as alreadySent.Add recipientKey to the end of alreadySent and save it as alreadySent.sendErrorIf a step below fails, save the error as sendErrorrecipient.Send an email to recipient.
Subject: emailSubjectemailBodytakenOffLog to false.Set takenOffLog to false.unlogErrorIf a step below fails, save the error as unlogErrorsentLogTabName in sheetUrl. Save it as logNow.Read column 'A' of sentLogTabName in sheetUrl. Save it as logNow.recipient in logNow.values and save it as logPosition.Find the position of recipient in logNow.values and save it as logPosition.logPosition is greater than or equal to 1If logPosition is greater than or equal to 1logRowNumber to logPosition plus 1.Set logRowNumber to logPosition plus 1.logRowNumber from sentLogTabName in sheetUrl.Delete row logRowNumber from sentLogTabName in sheetUrl.takenOffLog to true.Change takenOffLog to true.takenOffLog is yesIf takenOffLog is yesGmail did not send the email to , recipient, (, sendError.message, ). The run stopped there, after , sentCount as text, email(s) went out. , recipient, was taken off the ', sentLogTabName, ' tab, so the next run tries them again. Check your Gmail connection, then run it again.] joined by nothing.Stop and report a problem: [Gmail did not send the email to , recipient, (, sendError.message, ). The run stopped there, after , sentCount as text, email(s) went out. , recipient, was taken off the ', sentLogTabName, ' tab, so the next run tries them again. Check your Gmail connection, then run it again.] joined by nothing.Gmail did not send the email to , recipient, (, sendError.message, ). The run stopped there, after , sentCount as text, email(s) went out. , recipient, is still on the ', sentLogTabName, ' tab although nothing reached them: delete their row there so the next run tries them again, then check your Gmail connection.] joined by nothing.Stop and report a problem: [Gmail did not send the email to , recipient, (, sendError.message, ). The run stopped there, after , sentCount as text, email(s) went out. , recipient, is still on the ', sentLogTabName, ' tab although nothing reached them: delete their row there so the next run tries them again, then check your Gmail connection.] joined by nothing.sentCount.Add 1 to sentCount.sentCount as text opening emails. Skipped skippedRows as text rows.Show in the output: Sent sentCount as text opening emails. Skipped skippedRows as text rows.stoppedAtLimit is yesIf stoppedAtLimit is yesIt stopped at the limit of , sendLimit as text, email(s) set in 'maxEmailsThisRun'. Run it again to carry on down the list.] joined by nothing.Show in the output: [It stopped at the limit of , sendLimit as text, email(s) set in 'maxEmailsThisRun'. Run it again to carry on down the list.] joined by nothing.