Reads a lead list from Google Sheets, researches each prospect via web search, drafts personalised emails, routes senior contacts to Slack for approval, then sends and logs results — all automatically.
Check: python3 --version.
Get one at console.anthropic.com. We use Claude for drafting because its tone in sales copy is naturally persuasive without being pushy.
Tavily is a search API built for AI agents. Sign up for free at tavily.com — you get 1,000 free searches/month. This is what the agent uses to research each prospect's company before drafting the email.
Follow the same Google Cloud setup as in the Lead Qualifier example — create a project, enable Google Sheets API, download credentials.json, run auth.py once to generate token.json.
Create a Slack app with an incoming webhook for a channel like #sales-approvals. See the Lead Qualifier prerequisites for exact steps.
Use Gmail App Password or any SMTP provider. See the FNOL Agent prerequisites for Gmail App Password setup.
Go to sheets.google.com → New blank sheet → name it "Sales Outreach Pipeline".
Name | Title | Company | Email | Company Website | Status | Email Draft | Sent At | Response | Notes
Fill in at minimum: Name, Title, Company, Email, and Company Website. Leave Status blank — the agent will fill it in. Example row:
Sarah Chen | VP of Sales | Acme Corp | s.chen@acme.com | acme.com | | | | |
From the URL: https://docs.google.com/spreadsheets/d/[THIS PART]/edit. Add it to your .env file.
mkdir sales-outreach && cd sales-outreach
python3 -m venv venv
source venv/bin/activate
pip install anthropic tavily-python gspread google-auth \
google-auth-oauthlib requests python-dotenv
ANTHROPIC_API_KEY=sk-ant-...
TAVILY_API_KEY=tvly-...
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
GOOGLE_SHEET_ID=your-sheet-id
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=outreach@yourcompany.com
SMTP_PASSWORD=your-app-password
SENDER_NAME=Alex Johnson # Your name — appears in sent emails
SENIOR_TITLES=VP,Director,C-suite,CEO,CTO,CFO,COO,Chief,President
import os
import smtplib
import time
import requests
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime
from dotenv import load_dotenv
import anthropic
import gspread
from google.oauth2.credentials import Credentials
from tavily import TavilyClient
load_dotenv()
claude = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
tavily = TavilyClient(api_key=os.getenv("TAVILY_API_KEY"))
SENIOR_TITLES = [t.strip() for t in os.getenv("SENIOR_TITLES", "VP,Director").split(",")]
SENDER_NAME = os.getenv("SENDER_NAME", "Your Name")
# ── Google Sheets ─────────────────────────────────────────────────────────────
def get_sheet():
creds = Credentials.from_authorized_user_file('token.json')
client = gspread.authorize(creds)
return client.open_by_key(os.getenv("GOOGLE_SHEET_ID")).sheet1
def get_pending_leads(sheet):
"""Return rows where Status is empty (not yet processed)."""
rows = sheet.get_all_records()
pending = []
for i, row in enumerate(rows, start=2): # start=2 because row 1 is headers
if not row.get('Status'):
pending.append({'row_index': i, **row})
return pending
def update_row(sheet, row_index: int, updates: dict):
"""Update specific columns in a row."""
headers = sheet.row_values(1)
for col_name, value in updates.items():
if col_name in headers:
col_index = headers.index(col_name) + 1
sheet.update_cell(row_index, col_index, str(value))
# ── Web research ──────────────────────────────────────────────────────────────
def research_company(company: str, website: str) -> str:
"""Use Tavily to research a company before drafting the email."""
query = f"{company} {website} recent news product company"
try:
results = tavily.search(
query=query,
search_depth="basic",
max_results=3,
include_answer=True
)
summary = results.get('answer', '')
snippets = [r.get('content', '')[:300] for r in results.get('results', [])[:2]]
return f"{summary}\n\n" + "\n\n".join(snippets)
except Exception as e:
return f"Could not research company: {e}"
# ── Email drafting ────────────────────────────────────────────────────────────
def draft_email(lead: dict, company_research: str) -> str:
"""Use Claude to draft a personalised outreach email."""
prompt = f"""You are a sales development representative writing a personalised
cold outreach email.
PROSPECT DETAILS:
- Name: {lead['Name']}
- Title: {lead['Title']}
- Company: {lead['Company']}
- Website: {lead.get('Company Website', '')}
COMPANY RESEARCH:
{company_research}
SENDER: {SENDER_NAME}
INSTRUCTIONS:
1. Write a subject line and email body
2. Reference something specific and real from the company research
3. Lead with their perspective, not ours — what problem might they have?
4. Keep it under 150 words
5. One clear CTA: a 15-minute call
6. Tone: professional but human, not corporate
Format output as:
SUBJECT: [subject line here]
BODY:
[email body here]
Do NOT use generic phrases like "I hope this email finds you well"
or "I wanted to reach out". Be direct and specific."""
response = claude.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=500,
messages=[{"role": "user", "content": prompt}]
)
return response.content[0].text
def parse_email_draft(draft: str) -> tuple[str, str]:
"""Parse the SUBJECT/BODY format from Claude's output."""
lines = draft.strip().split('\n')
subject = ''
body = ''
in_body = False
for line in lines:
if line.startswith('SUBJECT:'):
subject = line.replace('SUBJECT:', '').strip()
elif line.strip() == 'BODY:':
in_body = True
elif in_body:
body += line + '\n'
return subject.strip(), body.strip()
# ── Slack approval for senior contacts ───────────────────────────────────────
def is_senior_contact(title: str) -> bool:
title_upper = title.upper()
return any(t.upper() in title_upper for t in SENIOR_TITLES)
def request_slack_approval(lead: dict, subject: str, body: str) -> str:
"""Post the draft to Slack with approval instructions. Returns 'pending'."""
payload = {
"blocks": [
{
"type": "header",
"text": {"type": "plain_text", "text": f"📨 Senior Contact — Approval Needed"}
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": f"*To:*\n{lead['Name']} ({lead['Title']})"},
{"type": "mrkdwn", "text": f"*Company:*\n{lead['Company']}"},
{"type": "mrkdwn", "text": f"*Email:*\n{lead['Email']}"},
]
},
{
"type": "section",
"text": {"type": "mrkdwn", "text": f"*Subject:* {subject}"}
},
{
"type": "section",
"text": {"type": "mrkdwn", "text": f"*Draft email:*\n```{body}```"}
},
{
"type": "section",
"text": {"type": "mrkdwn",
"text": "Reply with ✅ to send as-is, or copy/edit and send manually. "
"The sheet Status column is set to 'Awaiting Approval'."}
}
]
}
requests.post(os.getenv("SLACK_WEBHOOK_URL"), json=payload)
return "Awaiting Approval"
# ── Send email ────────────────────────────────────────────────────────────────
def send_email(to_email: str, to_name: str, subject: str, body: str):
"""Send the outreach email via SMTP."""
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = f"{SENDER_NAME} <{os.getenv('SMTP_USER')}>"
msg['To'] = f"{to_name} <{to_email}>"
full_body = f"{body}\n\n--\n{SENDER_NAME}"
msg.attach(MIMEText(full_body, 'plain'))
with smtplib.SMTP(os.getenv("SMTP_HOST"), int(os.getenv("SMTP_PORT"))) as server:
server.starttls()
server.login(os.getenv("SMTP_USER"), os.getenv("SMTP_PASSWORD"))
server.send_message(msg)
# ── Main agent ────────────────────────────────────────────────────────────────
def run_agent():
sheet = get_sheet()
leads = get_pending_leads(sheet)
print(f"Sales Outreach Agent — Found {len(leads)} pending prospects\n")
for lead in leads:
name = lead['Name']
company = lead['Company']
email = lead['Email']
title = lead['Title']
website = lead.get('Company Website', company)
print(f"Processing: {name} ({title}) at {company}")
# Research the company
print(f" → Researching {company}...")
research = research_company(company, website)
# Draft the email
print(f" → Drafting personalised email...")
draft = draft_email(lead, research)
subject, body = parse_email_draft(draft)
if not subject or not body:
print(f" ⚠️ Failed to parse email draft — skipping")
update_row(sheet, lead['row_index'], {'Status': 'Draft Failed'})
continue
# Update sheet with the draft
update_row(sheet, lead['row_index'], {
'Email Draft': f"Subject: {subject}\n\n{body}",
'Status': 'Draft Ready'
})
# Route senior contacts to Slack for approval
if is_senior_contact(title):
print(f" → Senior contact — sending to Slack for approval")
status = request_slack_approval(lead, subject, body)
update_row(sheet, lead['row_index'], {'Status': status})
# Auto-send for non-senior contacts
else:
print(f" → Sending email to {email}...")
try:
send_email(email, name, subject, body)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
update_row(sheet, lead['row_index'], {
'Status': 'Sent',
'Sent At': timestamp
})
print(f" ✓ Sent successfully")
except Exception as e:
print(f" ✗ Send failed: {e}")
update_row(sheet, lead['row_index'], {'Status': f'Send Failed: {e}'})
# Be a good citizen — don't hammer APIs
time.sleep(2)
print(f"\nDone. Check the Google Sheet for results.")
if __name__ == "__main__":
run_agent()
Add one row with your own name, a fake title, a real company (e.g. Apple Inc), and your own email address. Leave Status blank. This lets you see a real personalised email arrive in your own inbox.
source venv/bin/activate
python agent.py
Expected output:
Sales Outreach Agent — Found 3 pending prospects
Processing: Sarah Chen (VP of Sales) at Acme Corp
→ Researching Acme Corp...
→ Drafting personalised email...
→ Senior contact — sending to Slack for approval
Processing: Tom Baker (Account Manager) at Beta Ltd
→ Researching Beta Ltd...
→ Drafting personalised email...
→ Sending email to tom.baker@betaltd.com...
✓ Sent successfully
Processing: You (Test) at Apple Inc
→ Researching Apple Inc...
→ Drafting personalised email...
→ Sending email to your@email.com...
✓ Sent successfully
Done. Check the Google Sheet for results.
Important: Only run this on real prospects after you've reviewed a few sample drafts and are satisfied with the quality. Check your own email first to see what the output looks like before sending to prospects.
Check the "Sent At" column daily. If no response after 3 days, send a follow-up. After 7 more days, send a final breakup email. Three touches, fully automated.
Use Proxycurl or the LinkedIn API to pull the prospect's recent posts and activities. Include a reference to something they posted — response rates increase dramatically.
Have Claude generate 2 subject line variants. Randomly assign A or B in the sheet. After 50 sends each, analyse open rates and pick the winner.
Add a Gmail poller (similar to the Lead Qualifier) that watches for replies and updates the "Response" column in the sheet automatically. Close the loop on every outreach.