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 NoneTrack 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.repliedThe full system
Over four posts, you've built an autonomous sales agent that handles the complete lead lifecycle:
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