← Back to blog
TutorialMarch 14, 202610 min read

Add Email Follow-Ups to Your AI Call Agent

You've built a complete call agent: outbound calling with [CRM integration](/blog/tutorial-agent-crm-integration), and [event-driven triggers](/blog/tutorial-scheduling-triggers). In this final part, you'll add email follow-ups — so when a call goes to voicemail a second time, your agent automatically sends a short, personal message. Voicemail plus personalized email gets you to 15-25% response rate, based on industry benchmarks for personalized outreach.

How Spix email works

Spix email follows the same playbook model as calls. You create an inbox (your sending address), then send emails via the API. For automated follow-ups, you skip the playbook and send directly with a subject and body.

# Create an inbox for your agent
spix --json email inbox create --username sales --name "Sales Agent"
# Returns: [email protected]

# Send a test email
spix --json email send \
  --sender [email protected] \
  --to [email protected] \
  --subject "Quick follow-up" \
  --body "Hi Sarah, I tried calling earlier..."

Generate the follow-up email

Keep it short. The goal is to get a reply, not to sell the entire product. Personalize the opener based on the lead's source. Short, personal, one ask.

# Python
def generate_followup_email(lead: dict) -> dict:
    """Generate a personalized follow-up email from lead data.
    Returns dict with subject and body."""

    props = lead.get("properties", {})
    name = props.get("firstname", "there")
    company = props.get("company", "")
    source = props.get("hs_analytics_source", "direct")

    openers = {
        "ORGANIC_SEARCH": f"I saw {company} has been researching AI calling solutions",
        "PAID_SEARCH": f"Thanks for checking out Spix",
        "REFERRAL": f"A colleague pointed me to {company}",
        "DIRECT_TRAFFIC": f"I noticed you signed up to learn more about Spix",
    }
    opener = openers.get(source, f"I wanted to connect about {company}'s interest in Spix")

    subject = f"Quick note for {name}"
    body = (
        f"Hi {name},\n\n"
        f"{opener}. I tried calling a couple of times but didn't catch you.\n\n"
        f"Would a 15-minute call this week work? I can walk through "
        f"how teams like {company} are using AI agents for outbound.\n\n"
        f"Just reply with a time that works, or grab a slot here: "
        f"https://cal.com/your-team/15min\n\n"
        f"Best,\nAlex"
    )

    return {"subject": subject, "body": body}

Send the email via Spix API

The Spix email API accepts a simple POST with sender, recipient, subject, and body:

# Python
import requests
from config import SPIX_API_KEY, SPIX_EMAIL_INBOX


def send_followup(to_email: str, subject: str, body: str) -> dict:
    """Send a follow-up email via the Spix email API."""
    resp = requests.post(
        "https://api.spix.sh/v1/emails/send",
        headers={"Authorization": f"Bearer {SPIX_API_KEY}"},
        json={
            "sender": SPIX_EMAIL_INBOX,
            "to": to_email,
            "subject": subject,
            "body": body,
        },
    )
    return resp.json()

Update check_and_retry with email fallback

In Part 3, check_and_retry fired a second call. Now, you'll track the attempt count in Redis. After two unanswered calls, send an email instead of calling again.

# Python
@app.task
def check_and_retry(lead_id: str):
    """After 2 failed calls, switch to email follow-up."""

    if not r.exists(f"spix:retry:{lead_id}"):
        return {"status": "no_retry_needed", "lead_id": lead_id}

    attempt_key = f"spix:attempts:{lead_id}"
    attempts = int(r.get(attempt_key) or 0)

    if attempts < 1:
        r.incr(attempt_key)
        r.expire(attempt_key, 86400)
        r.delete(f"spix:retry:{lead_id}")
        call_lead.delay(lead_id)
        return {"status": "retrying_call", "attempt": attempts + 1}

    # Two calls failed — send email follow-up
    r.delete(f"spix:retry:{lead_id}")
    r.delete(attempt_key)
    send_email_followup.delay(lead_id)
    return {"status": "switching_to_email", "lead_id": lead_id}


@app.task
def send_email_followup(lead_id: str):
    """Fetch lead data, generate email, send via Spix."""
    import requests as req
    from email_generator import generate_followup_email
    from email_sender import send_followup
    from config import HUBSPOT_TOKEN

    lead = req.get(
        f"https://api.hubapi.com/crm/v3/objects/contacts/{lead_id}",
        headers={"Authorization": f"Bearer {HUBSPOT_TOKEN}"},
    ).json()

    email = lead.get("properties", {}).get("email")
    if not email:
        return {"status": "skipped", "reason": "no_email"}

    msg = generate_followup_email(lead)
    result = send_followup(email, msg["subject"], msg["body"])

    # Log to HubSpot
    req.post(
        "https://api.hubapi.com/crm/v3/objects/notes",
        headers={"Authorization": f"Bearer {HUBSPOT_TOKEN}"},
        json={
            "properties": {
                "hs_note_body": f"Email follow-up sent: {msg['subject']}",
            },
            "associations": [{
                "to": {"id": lead_id},
                "types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 202}],
            }],
        },
    )

    return {"status": "email_sent", "to": email}

The complete sequence

Here's the full automated flow, end to end:

  • Form submitted → Flask handler fires call_lead.delay()
  • Call attempt 1 (immediate) → Phone rings within 4 seconds
  • If unanswered → check_and_retry fires after 30 minutes
  • Call attempt 2 → Second try with the same briefing
  • If still unanswered → send_email_followup fires automatically
  • Personalized email lands in inbox → Logged to HubSpot
  • Email opened or replied → Webhook fires, lead status updated

Look up contacts by email

The email event handler needs to map a recipient address back to a CRM contact. Add this helper alongside your existing phone lookup:

# Python
def lookup_contact_by_email(email: str) -> dict | None:
    """Search HubSpot for a contact by email address."""
    resp = requests.post(
        "https://api.hubapi.com/crm/v3/objects/contacts/search",
        headers={"Authorization": f"Bearer {HUBSPOT_TOKEN}"},
        json={"filterGroups": [{"filters": [{"propertyName": "email", "operator": "EQ", "value": email}]}]},
    )
    results = resp.json().get("results", [])
    return results[0] if results else None

Track opens and replies

Spix fires webhook events when emails are opened or replied to. A reply is a high-intent signal — update the lead status and optionally trigger an immediate follow-up call.

# Python
import requests

@app.route("/webhooks/email-events", methods=["POST"])
def email_events():
    """Handle email.opened and email.replied webhooks from Spix."""
    payload = request.json
    event = payload.get("event")
    to_email = payload.get("to")

    if event == "email.replied":
        contact = lookup_contact_by_email(to_email)
        if contact:
            requests.patch(
                f"https://api.hubapi.com/crm/v3/objects/contacts/{contact['id']}",
                headers={"Authorization": f"Bearer {HUBSPOT_TOKEN}"},
                json={"properties": {"hs_lead_status": "REPLIED"}},
            )

    elif event == "email.opened":
        r.incr(f"spix:email_opens:{to_email}")

    return jsonify({"ok": True}), 200


# Subscribe to email events:
# spix --json webhook endpoint create \
#   --url https://your-server.com/webhooks/email-events \
#   --events email.opened,email.replied

The full system

Over four posts, you've built an autonomous sales agent that handles the complete lead lifecycle:

  • Part 1: CLI-triggered outbound calls with persona and goal
  • Part 2: CRM-connected calls with dynamic briefings and outcome logging
  • Part 3: Event-driven triggers — form to call in under 60 seconds
  • Part 4: Email follow-ups after unanswered calls

What's next

You've built a complete autonomous sales agent — from first touch to follow-up email. To explore the full Spix API, check docs.spix.sh or install the MCP server: spix mcp install claude