Hasnain AliHire Me
System Design

How I Sent 117K+ Personalised Emails and 10K+ AI Voice Calls in One Day Using GCP Pub/Sub

Hasnain Ali — Senior AI Engineer|April 2026|14 min read
117K+
Personalised Emails
Sent in under 6 hours
10K+
AI Voice Calls
Twilio + ElevenLabs
4
GCP Services
Pub/Sub, Cloud Run, BQ, GCS
$2M+
ARR Attributed
Direct pipeline revenue

This is a detailed engineering breakdown of the AI outreach infrastructure I built at Speculo.ai. The system processed 117K+ leads, generated personalised emails and call scripts using LLMs, and dispatched everything autonomously using a four-stage GCP Pub/Sub pipeline with parallel Cloud Run workers. No humans in the loop. $2M+ in attributed revenue. This is exactly how it works.

The Problem: Human SDRs Cannot Scale

A traditional inside sales team of 10 SDRs can realistically send 50–80 personalised emails per person per day. That is a ceiling of 800 emails per day at significant cost — salaries, benefits, management overhead, inconsistent quality, and limited operating hours.

The ask at Speculo.ai was different: reach tens of thousands of qualified prospects every week, each with a message personalised to their specific company, recent news, and role — and follow up with a voice call if they did not respond. The unit economics of doing this with humans do not work at that scale.

The only viable path was a fully autonomous pipeline. The core engineering challenge was not the AI — generating good copy with GPT-4o is straightforward. The challenge was building infrastructure that could process 117,000 jobs reliably, in parallel, without losing messages, without hammering rate limits, and with full observability.

Architecture Overview

The system is built on four layers, each decoupled via GCP Pub/Sub topics. Each layer publishes to the next. Workers in each layer are stateless Cloud Run containers that scale horizontally based on subscription backlog.

GCP Pub/Sub Architecture
117K Emails + 10K AI Voice Calls — Autonomous Outreach Pipeline
Layer 1 — Data Sources
📋
CRM / CSV Export
Salesforce, HubSpot leads
🕷️
Web Scraper
Company data enrichment
🔍
Apollo + Hunter.io
Contact finder & verifier
converge
Lead Validator
Cloud Function — dedup · schema check · ICP score
publish
Pub/Sub Topic
leads-raw
subscribe (push)
Layer 2 — Enrichment Workers (Cloud Run · auto-scale 0 → 50 instances)
Enrichment Worker
Tavily search · Gemini summarise · ICP rescore
Enrichment Worker
Parallel instance, same subscription
Worker × N
Cloud Run scales on backlog depth
Dead Letter Queue
leads-raw-dlq
fail after 3 retries
publish
Pub/Sub Topic
leads-enriched
subscribe (push)
Layer 3 — LLM Personalisation (Cloud Run · auto-scale 0 → 30 instances)
🧠
LLM Personalisation
GPT-4o · personalised email + call script · JSON structured output · quality gate ≥ 8/10
Token Bucket
Redis rate limiter
RPM · TPM · per key
Exponential backoff
publish
Pub/Sub Topic
messages-ready
FAN OUT
Email Dispatch
📧 SMTP Workers × 20
3 warmed sending domains
SPF + DKIM + DMARC
500 msg/hr/domain limit
Firestore idempotency guard
117,420 emails sent
Voice Dispatch
📞 Twilio + ElevenLabs
LangGraph conversation state
Real-time audio streaming
AMD detection enabled
Transcript → BigQuery
10,340 AI calls made
Observability
📊 BigQuery Analytics
Every event streamed in real-time
Partitioned by date
Funnel + conversion queries
Cloud Monitoring alerts
$0.0019 / lead total
Pub/Sub Topic
Cloud Run Workers (auto-scale)
Dead Letter Queue (DLQ)
Email Delivery
Voice + External APIs

Full pipeline from lead ingestion to email and voice dispatch. All inter-layer communication goes through Pub/Sub topics.

The four layers are:

LayerResponsibilityGCP ServiceScale
1 — IngestionValidate, deduplicate, score leads from CRM/scraper/ApolloCloud FunctionsEvent-triggered
2 — EnrichmentResearch company, generate ICP score, summarise contextCloud Run0 → 50 instances
3 — PersonalisationLLM generates email subject/body and call scriptCloud Run0 → 30 instances
4 — DispatchSMTP email delivery and Twilio voice call initiationCloud Run0 → 80 instances

Why GCP Pub/Sub and Not a Task Queue?

I evaluated Celery with Redis, BullMQ, and Google Cloud Tasks before settling on Pub/Sub. The decision came down to three requirements:

1. Fan-out publishing. Some leads need email only. Some get email and a follow-up call. Some are filtered and dropped. With Pub/Sub, a single enrichment worker can publish one message and multiple downstream subscribers decide independently whether to act. Cloud Tasks would have required explicit routing logic in the publisher.

2. Exactly-once processing with acknowledgement. At 117K messages, any duplicate email delivery is a reputational disaster. Pub/Sub's per-subscription acknowledgement with configurable ack deadline ensured each message was processed exactly once. If a worker crashed mid-processing, the message was redelivered to another instance — not lost, not duplicated.

3. Native Cloud Run scaling integration.Cloud Run's Pub/Sub push subscription triggers automatically scale instances based on backlog. No external autoscaler needed. When 50,000 messages hit the enrichment topic simultaneously, Cloud Run spun up 48 instances within 90 seconds and chewed through the backlog.

Key insight:Do not design your message queue around your current load. We initially set max instances to 10 for the enrichment layer and the backlog grew unbounded. The fix was raising max instances to 50 and adding a concurrency limit of 5 per instance. Pub/Sub's backlog metrics in Cloud Monitoring make this easy to tune.

The Four Pub/Sub Topics

Topic 1: leads-raw

Every lead from every source (CRM export, Apollo API, web scraper output) is published here after a lightweight Cloud Function validates the schema, deduplicates against a Firestore set of seen lead IDs, and computes an initial ICP score based on company size, industry, and role title. Malformed records are sent directly to a dead letter topic.

# Cloud Function: publish to leads-raw
import json
from google.cloud import pubsub_v1

publisher = pubsub_v1.PublisherClient()
TOPIC = "projects/speculo-prod/topics/leads-raw"

def ingest_lead(lead: dict) -> str:
    if not is_valid(lead):
        publish_dlq(lead, reason="schema_invalid")
        return "rejected"

    lead["icp_score"] = compute_icp_score(lead)
    lead["lead_id"]   = deduplicate_id(lead)   # SHA256 of email+domain

    future = publisher.publish(
        TOPIC,
        data=json.dumps(lead).encode("utf-8"),
        source=lead.get("source", "unknown"),   # message attributes for filtering
    )
    return future.result()  # blocks only until broker acks, ~5ms

Topic 2: leads-enriched

Enrichment workers subscribe to leads-raw, fire two parallel requests (Tavily for company news, Hunter.io for contact verification), and publish the enriched payload here. The ack deadline is set to 120 seconds to accommodate slow API responses. If enrichment fails after 3 retries, the message routes to leads-raw-dlq with a failure reason tag.

# Enrichment worker — runs in Cloud Run (stateless)
import asyncio
from google.cloud import pubsub_v1
from agents import enrich_company, verify_contact

subscriber = pubsub_v1.SubscriberClient()
PULL_SUB   = "projects/speculo-prod/subscriptions/enrichment-sub"
PUSH_TOPIC = "projects/speculo-prod/topics/leads-enriched"

async def process(message: pubsub_v1.types.ReceivedMessage):
    lead = json.loads(message.message.data)
    try:
        company_ctx, contact = await asyncio.gather(
            enrich_company(lead["domain"]),   # Tavily + Gemini summarise
            verify_contact(lead["email"]),     # Hunter.io + MX check
        )
        if not contact["deliverable"]:
            message.nack()   # requeue for DLQ after max attempts
            return

        enriched = {**lead, "company_ctx": company_ctx, "contact": contact}
        publisher.publish(PUSH_TOPIC, json.dumps(enriched).encode())
        message.ack()
    except Exception as e:
        log_error(e, lead["lead_id"])
        message.nack()   # retry with exponential backoff

Topic 3: messages-ready

The personalisation layer is where the LLM calls happen. Each worker receives an enriched lead and generates three things: an email subject line, an HTML email body (~120 words, personalised to the company's specific context), and a 60-second call script for the voice agent. All three are generated in a single LLM call using structured output (JSON mode) to avoid parsing failures.

# Personalisation worker — GPT-4o structured output
async def personalise(enriched: dict) -> dict:
    prompt = build_prompt(
        candidate  = HASNAIN_PROFILE,
        company    = enriched["company_ctx"],
        contact    = enriched["contact"],
        news_hook  = enriched["company_ctx"]["recent_news"][0],
    )
    response = await openai.chat.completions.create(
        model           = "gpt-4o",
        messages        = [{"role": "user", "content": prompt}],
        response_format = {"type": "json_object"},
        temperature     = 0.7,
        max_tokens      = 800,
    )
    result = json.loads(response.choices[0].message.content)

    # Hard quality gate — discard and requeue if score < 8
    if result.get("review_score", 0) < 8:
        return requeue_for_repersonalisation(enriched)

    return {
        **enriched,
        "subject"     : humanise(result["subject"]),   # strip AI tells
        "body_html"   : humanise(result["body_html"]),
        "call_script" : result["call_script"],
        "channel"     : determine_channel(enriched),   # email | voice | both
    }

The Hardest Problem: Rate Limiting at Scale

With 50 enrichment workers and 30 personalisation workers all hitting the same LLM APIs simultaneously, you will exhaust your rate limits in minutes. This was the most painful part of the build. Here is the strategy that worked:

Token Bucket per API Key

Each API key has its own token bucket stored in Cloud Memorystore (Redis). Every worker requests a token before making an API call. If the bucket is empty, the worker sleeps and retries with exponential backoff — it does not nack the Pub/Sub message, preserving its position in the queue.

# Distributed token bucket — Cloud Memorystore (Redis)
import redis
import time

r = redis.Redis.from_url(os.environ["MEMORYSTORE_URL"])

def acquire_token(api_key: str, rpm_limit: int) -> bool:
    """
    Returns True if token acquired, False if rate limit reached.
    Uses Redis atomic increment with TTL-based window.
    """
    bucket_key = f"ratelimit:{api_key}:{int(time.time() // 60)}"
    count = r.incr(bucket_key)
    if count == 1:
        r.expire(bucket_key, 65)   # slightly over 60s to avoid edge cases
    return count <= rpm_limit

async def call_with_backoff(fn, api_key: str, rpm: int, max_attempts=5):
    for attempt in range(max_attempts):
        if acquire_token(api_key, rpm):
            return await fn()
        wait = min(4 ** attempt, 30)   # 1s, 4s, 16s, 30s cap
        await asyncio.sleep(wait)
    raise RateLimitExhausted(f"Key {api_key[:8]}... exhausted after {max_attempts} attempts")

Key Rotation Pool

Rather than one API key per service, we maintained a pool of keys with a round-robin scheduler. Each key tracks its own daily usage in BigQuery. Once a key hits 90% of its daily quota, the scheduler sidelines it for the rest of the day and routes traffic to the next available key. Free tier keys (1,000 RPD) are exhausted first; paid keys handle overflow.

# Key pool scheduler — daily quota tracking
class KeyPool:
    def __init__(self, keys: list[str], daily_limit: int):
        self.keys         = keys
        self.daily_limit  = daily_limit
        self._idx         = 0

    def acquire(self) -> str | None:
        for _ in range(len(self.keys)):
            key   = self.keys[self._idx % len(self.keys)]
            usage = get_daily_usage(key)          # BigQuery lookup, cached 60s
            if usage < self.daily_limit * 0.9:    # 90% threshold
                self._idx += 1
                return key
            self._idx += 1
        return None   # all keys exhausted — pipeline pauses

KEY_POOL = KeyPool(
    keys        = os.environ["GEMINI_KEYS"].split(","),
    daily_limit = 1000,   # free tier RPD per project
)

Email Dispatch: 117K Messages, Zero Duplicates

The email dispatch layer had one non-negotiable requirement: every lead receives exactly one email, even if a worker crashes mid-send. The pattern we used was a two-phase commit tracked in Firestore:

# Two-phase send with idempotency check
from google.cloud import firestore

db = firestore.Client()

async def dispatch_email(message: dict) -> bool:
    lead_id  = message["lead_id"]
    send_ref = db.collection("sent_emails").document(lead_id)

    # Phase 1: claim the send slot (atomic transaction)
    @firestore.transactional
    def claim(txn):
        snap = send_ref.get(transaction=txn)
        if snap.exists:
            return False   # already sent — idempotent skip
        txn.set(send_ref, {"status": "pending", "ts": firestore.SERVER_TIMESTAMP})
        return True

    txn = db.transaction()
    if not claim(txn):
        return False   # another worker already handled this lead

    # Phase 2: actually send
    try:
        await send_via_smtp(message)
        send_ref.update({"status": "sent", "sent_at": firestore.SERVER_TIMESTAMP})
        stream_to_bigquery(message, event="email_sent")
        return True
    except Exception as e:
        send_ref.update({"status": "failed", "error": str(e)})
        raise   # Pub/Sub will redeliver to another worker

Sending domain warmup is critical at this volume. We used three warmed sending domains with proper SPF, DKIM, and DMARC records, rotating messages across them to avoid triggering spam filters. The SMTP workers enforced a maximum of 500 messages per hour per domain.

Voice Dispatch: 10K+ AI Calls via Twilio and ElevenLabs

Voice calls used the same Pub/Sub pattern as email, with a separate subscription on messages-ready filtered to leads with channel=voice or channel=both.

Each call was made using Twilio Programmable Voice with a TwiML webhook that streamed the ElevenLabs voice in real time. The call agent used a LangGraph state machine to handle the conversation: opening hook, qualification questions, objection handling, and calendar booking. Call transcripts were written to BigQuery within 30 seconds of completion.

# Voice call initiation — Twilio + ElevenLabs
from twilio.rest import Client as TwilioClient

twilio = TwilioClient(ACCOUNT_SID, AUTH_TOKEN)

async def initiate_call(lead: dict) -> str:
    call = twilio.calls.create(
        to   = lead["contact"]["phone"],
        from_= WARM_CALLER_ID,
        url  = f"{WEBHOOK_BASE}/voice/start?lead_id={lead['lead_id']}",
        # TwiML webhook streams ElevenLabs audio + handles DTMF
        status_callback = f"{WEBHOOK_BASE}/voice/events",
        machine_detection = "Enable",   # skip answering machines
        machine_detection_timeout = 4,
    )
    stream_to_bigquery(lead, event="call_initiated", call_sid=call.sid)
    return call.sid

# TwiML webhook — served by Cloud Run
@app.post("/voice/start")
async def voice_start(lead_id: str):
    lead   = get_lead(lead_id)
    script = lead["call_script"]
    audio_url = await elevenlabs.generate_audio(
        text  = script["opening"],
        voice = VOICE_ID,   # consistent voice across all calls
    )
    return TwiML.gather_response(audio_url, action="/voice/respond")

Observability: BigQuery as the Source of Truth

Every meaningful event — lead ingested, enrichment succeeded, email sent, call completed, reply received — streamed to a BigQuery dataset in real time using the Storage Write API. This gave us a single queryable audit log across the entire pipeline.

# BigQuery streaming event schema
# events table — partitioned by DATE(event_ts)
CREATE TABLE speculo.pipeline_events (
  lead_id       STRING NOT NULL,
  event_type    STRING NOT NULL,   -- ingest | enrich | personalise | email_sent | call_started | reply
  event_ts      TIMESTAMP,
  domain        STRING,
  contact_email STRING,
  channel       STRING,
  metadata      JSON,              -- flexible per-event payload
  worker_id     STRING,            -- Cloud Run instance ID for debugging
)
PARTITION BY DATE(event_ts)
CLUSTER BY event_type, domain;

-- Daily funnel query
SELECT
  event_type,
  COUNT(*) AS count,
  COUNT(*) / LAG(COUNT(*)) OVER (ORDER BY stage_order) AS conversion_rate
FROM speculo.pipeline_events
WHERE DATE(event_ts) = CURRENT_DATE()
GROUP BY event_type, stage_order
ORDER BY stage_order;

Cloud Monitoring dashboards tracked Pub/Sub backlog per topic, Cloud Run instance count, and p95 processing latency per layer. Alerting fired if any topic backlog exceeded 5,000 messages for more than 10 minutes — an indicator that a layer was unhealthy.

Results

117,420
Emails Dispatched
5h 42min total runtime
10,340
AI Calls Initiated
68% answer rate
3.1%
Email Reply Rate
4× industry average (0.7%)
0
Duplicate Sends
Idempotency held at scale
99.4%
Email Deliverability
3 warmed domains, DMARC
$0.0019
Cost Per Lead
Email + call combined
LayerPeak InstancesAvg LatencyError RateDLQ Messages
Enrichment488.4s0.3%362
Personalisation293.1s0.8%940
Email Dispatch760.6s0.1%118
Voice Dispatch421.2s1.2%124

What I Would Do Differently

Use Pub/Sub Lite for the enrichment layer. Standard Pub/Sub is global with multi-region replication. For a high-throughput internal queue where we control both publisher and subscriber, Pub/Sub Lite gives similar semantics at roughly 10× lower cost per message. We switched after the first month and cut queue costs by 85%.

Separate the quality gate into its own service. In the current design, the personalisation worker scores the output and requeues failures. This means failed messages re-enter the personalisation queue and count against our LLM quota. A dedicated scoring worker that is LLM-free (rule-based + fast heuristics) would catch the obvious failures before spending tokens on them.

Implement proper send-time scheduling. We sent at constant rate across the day. Research (and our own data) shows that emails sent between 8–10am recipient local time get 2.3× higher open rates. A send-time optimisation layer that buffers messages and dispatches at the optimal local window would have materially improved conversion.

Building a high-scale AI pipeline?

I have designed and shipped agentic systems that process hundreds of thousands of operations autonomously. If you are building something in this space and want to talk architecture, get in touch.

Get in Touch

Written by Hasnain Ali, Senior AI Engineer. Specialising in production LLM systems, agentic pipelines, and GCP infrastructure. Connect on LinkedIn.