← Back to blog
TutorialMarch 8, 202613 min read

Connect Your AI Call Agent to a CRM

In [Part 1](/blog/tutorial-ai-agent-phone-number), you made your first outbound call with a static briefing. The agent knew nothing about who it was calling. In this post, you'll change that — pulling real lead data before each call and writing outcomes back when it ends. By the end, your call agent will know the prospect's name, company, what they signed up for, and any previous interaction history — before it dials.

What you're building

A Python script that pulls a lead from HubSpot (or a CSV if you don't have HubSpot), builds a dynamic briefing from that lead's data, fires a Spix call with that briefing, and captures the call outcome via webhook to write it back to the CRM.

Prerequisites

  • Python 3.10+
  • pip install requests python-dotenv hubspot-api-client flask redis
  • A Spix API key (from Part 1)

Why briefing quality matters

Your AI agent's conversational quality is largely determined by its briefing — the context it receives before the call starts. A static briefing produces generic conversations. A dynamic briefing built from real CRM data produces calls that feel warm and prepared.

Without CRM context

When the briefing is just "You're calling a prospect who signed up recently. Try to book a demo," the agent opens like this:

[AGENT] Hi there, this is Alex from Spix. I'm reaching out to people
        who've shown interest in our product. Do you have a couple minutes?
[HUMAN] Who is this? What product?
[AGENT] We're a communications platform for AI agents. I was hoping
        to schedule a quick demo...

With CRM context

When the briefing includes real lead data — name, company, how they found you, what they care about — the same model produces a fundamentally different conversation:

[AGENT] Hi Sarah, this is Alex from Spix. I noticed you checked out our
        MCP blog post a few days ago — looked like you were comparing us
        to Bland. I'd love to show you where we differ. Do you have two minutes?
[HUMAN] Oh, yeah, we've actually been looking at voice agent options.
        Bland didn't handle multi-turn well.
[AGENT] That's exactly what we hear. Our conversation engine handles
        multi-turn with external memory natively. If I could get 15 minutes
        on your calendar, I'll walk you through a live demo...

Same model, same voice, same goal — completely different call. The difference is the briefing.

Environment setup

SPIX_API_KEY=spix_live_sk_your_key_here
SPIX_PHONE_NUMBER=+14155550101
SPIX_PLAYBOOK_ID=plb_call_abc123
HUBSPOT_ACCESS_TOKEN=your_hubspot_private_app_token
REDIS_URL=redis://localhost:6379

Build the dynamic briefing

This is the core function. It takes a lead dict and produces the briefing string that gets injected into the AI's context window before the call connects.

# Python
def build_briefing(lead: dict) -> str:
    """Turn a CRM lead into a rich briefing string for the AI agent."""
    lines = [
        f"You are calling {lead['name']}, {lead['title']} at {lead['company']}.",
    ]

    source_context = {
        "mcp-blog": "They clicked our MCP integration blog post.",
        "direct": "They came directly to spix.sh.",
        "newsletter": "They signed up for the Spix newsletter.",
        "referral": "They were referred by an existing customer.",
    }
    source_line = source_context.get(
        lead.get("source", "unknown"),
        f"They found us via: {lead.get('source', 'unknown')}."
    )
    lines.append(source_line)

    if lead.get("notes"):
        lines.append(f"Previous context: {lead['notes']}")

    status = lead.get("lead_status", "new")
    if status == "new":
        lines.append("This is our first contact with this person.")
    elif status == "ATTEMPTED_TO_CONTACT":
        lines.append("We have tried reaching them before but did not connect.")

    lines.extend([
        "",
        "Goal: Book a 15-minute product demo.",
        "Keep it under 3 minutes. If they can't talk, offer to send a short video instead.",
    ])

    return "\n".join(lines)

Fire the call via the Spix API

The key field is briefing_override — it lets you keep the playbook's persona, goal, and voice config while injecting dynamic context per call.

# Python
import os
import requests

SPIX_API = "https://api.spix.sh"

def create_call(to: str, briefing: str) -> dict:
    """Fire an outbound call via the Spix API with a dynamic briefing."""
    response = requests.post(
        f"{SPIX_API}/v1/calls",
        headers={
            "Authorization": f"Bearer {os.environ['SPIX_API_KEY']}",
            "Content-Type": "application/json",
        },
        json={
            "to": to,
            "playbook_id": os.environ["SPIX_PLAYBOOK_ID"],
            "sender": os.environ["SPIX_PHONE_NUMBER"],
            "briefing_override": briefing,
        }
    )
    response.raise_for_status()
    return response.json()["data"]

Track sessions with Redis

Map Spix session IDs back to CRM lead IDs so your webhook handler knows which lead a call belongs to.

# Python
import os
import redis

r = redis.from_url(os.environ.get("REDIS_URL", "redis://localhost:6379"))

def track_session(session_id: str, lead_id: str):
    """Map a Spix session ID to a CRM lead ID with 24h TTL."""
    r.setex(f"spix:session:{session_id}", 86400, lead_id)

def get_lead_for_session(session_id: str) -> str | None:
    """Look up which lead a session belongs to."""
    result = r.get(f"spix:session:{session_id}")
    return result.decode() if result else None

Capture outcomes via webhook

Spix fires a call.ended webhook when a call finishes. Your handler receives the structured outcome and writes it back to HubSpot.

# Python
import os
import time
from flask import Flask, request, jsonify
from hubspot import HubSpot
from session_store import get_lead_for_session

app = Flask(__name__)


@app.route("/webhooks/spix", methods=["POST"])
def spix_webhook():
    event = request.json

    if event.get("event") != "call.ended":
        return jsonify({"ok": True})

    data = event["data"]
    session_id = data["session_id"]
    outcome = data["outcome"]
    summary = data["summary"]
    duration = data["duration_seconds"]

    lead_id = get_lead_for_session(session_id)
    if not lead_id:
        return jsonify({"ok": True})

    # Write outcome back to HubSpot
    client = HubSpot(access_token=os.environ["HUBSPOT_ACCESS_TOKEN"])

    client.crm.objects.notes.basic_api.create(
        simple_public_object_input_for_create={
            "properties": {
                "hs_note_body": f"Spix call ({duration}s): {summary}",
                "hs_timestamp": str(int(time.time() * 1000)),
            },
            "associations": [{
                "to": {"id": lead_id},
                "types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 202}]
            }]
        }
    )

    status_map = {
        "goal_met": "CONNECTED",
        "goal_not_met": "CONNECTED",
        "no_answer": "ATTEMPTED_TO_CONTACT",
    }
    client.crm.contacts.basic_api.update(
        lead_id,
        simple_public_object_input={"properties": {"hs_lead_status": status_map.get(outcome, "ATTEMPTED_TO_CONTACT")}}
    )

    return jsonify({"ok": True})

Production notes

  • Webhook verification: In production, verify the webhook signature using the signing secret from app.spix.sh → Webhooks.
  • Concurrency: The Agent plan allows 2 concurrent calls. Add a semaphore for list dialing, or upgrade to Operator (10 concurrent).
  • Error handling: Retry on 429 (rate limit) and 503 (service unavailable) with exponential backoff.
  • Idempotency: Use the session_id as an idempotency key — Spix may deliver a webhook more than once.

What's next

Right now you're triggering calls manually. In Part 3, we'll add an event-driven trigger — so when a new lead fills out a form, a call goes out automatically within 60 seconds.