Jump to: The Problem Prerequisites Google Sheet Setup The Code Run It Extend It
The Problem This Solves

Without Agent

  • Sales rep researches each prospect manually (15 min each)
  • Copy-pastes a generic template
  • Tries to personalise by hand
  • Sends without a second review
  • Spreadsheet never gets updated
  • 3–4 hrs to send 20 outreach emails

With Agent

  • Reads all prospects from Google Sheets
  • Researches each company via web search
  • Drafts a unique, personalised email
  • Routes VP/C-suite to Slack for human approval
  • Sends approved emails + updates sheet
  • 20 min to send 20 personalised emails
Prerequisites
Step 1 — Set Up the Google Sheet
  1. 1

    Create the lead list spreadsheet

    Go to sheets.google.com → New blank sheet → name it "Sales Outreach Pipeline".

  2. 2

    Add these headers in Row 1 (columns A through J)

    Name | Title | Company | Email | Company Website | Status | Email Draft | Sent At | Response | Notes

  3. 3

    Add your prospects (Rows 2 onward)

    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 | | | | |

  4. 4

    Note the Sheet ID

    From the URL: https://docs.google.com/spreadsheets/d/[THIS PART]/edit. Add it to your .env file.

Step 2 — Project Setup
bash
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
.env
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
Step 3 — The Agent Code
agent.py — full sales outreach agent
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()
Step 4 — Run and Test
  1. 1

    Add a test row to your sheet

    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.

  2. 2

    Run the agent

bash
source venv/bin/activate
python agent.py

Expected output:

Expected console 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.

Extend It

Automated follow-up sequences

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.

LinkedIn profile research

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.

A/B test subject lines

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.

Reply detection

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.