How I Sent 117K+ Personalised Emails and 10K+ AI Voice Calls in One Day Using GCP Pub/Sub
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.
RPM · TPM · per key
Exponential backoff
SPF + DKIM + DMARC
500 msg/hr/domain limit
Firestore idempotency guard
Real-time audio streaming
AMD detection enabled
Transcript → BigQuery
Partitioned by date
Funnel + conversion queries
Cloud Monitoring alerts
Full pipeline from lead ingestion to email and voice dispatch. All inter-layer communication goes through Pub/Sub topics.
The four layers are:
| Layer | Responsibility | GCP Service | Scale |
|---|---|---|---|
| 1 — Ingestion | Validate, deduplicate, score leads from CRM/scraper/Apollo | Cloud Functions | Event-triggered |
| 2 — Enrichment | Research company, generate ICP score, summarise context | Cloud Run | 0 → 50 instances |
| 3 — Personalisation | LLM generates email subject/body and call script | Cloud Run | 0 → 30 instances |
| 4 — Dispatch | SMTP email delivery and Twilio voice call initiation | Cloud Run | 0 → 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.
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, ~5msTopic 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.
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 backoffTopic 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.
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.
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.
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:
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 workerSending 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.
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.
# 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
| Layer | Peak Instances | Avg Latency | Error Rate | DLQ Messages |
|---|---|---|---|---|
| Enrichment | 48 | 8.4s | 0.3% | 362 |
| Personalisation | 29 | 3.1s | 0.8% | 940 |
| Email Dispatch | 76 | 0.6s | 0.1% | 118 |
| Voice Dispatch | 42 | 1.2s | 1.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 TouchWritten by Hasnain Ali, Senior AI Engineer. Specialising in production LLM systems, agentic pipelines, and GCP infrastructure. Connect on LinkedIn.