Most "AI outbound" products are a thin wrapper around a single LLM call and someone else's prospecting database. The hard part isn't generating an email. It's doing everything around the email correctly, at scale, cheaply, and reliably enough that a go-to-market team can trust the output of a single button click.
Campaign Engine architecture is the set of systems that turn CRM records into a running, deliverable outbound campaign: enrichment, scoring, identity resolution, generation, sending, and tracking, wired together behind one action. Campaign Engine looks simple from the outside: describe your audience, and a campaign gets built and sent from data you already own. Under the hood, that one click orchestrates enrichment, multi-provider research, propensity scoring, identity resolution, activity tracking, AI generation, deliverability logic, and a per-org queueing system, all metered and observed end to end.
Every campaign runs through six stages: compile the authored sequence into an executable campaign, trigger on CRM events, enroll the right contacts, send each step through the right inbox, track what happened, and meter every step for cost and performance. This post walks each stage, then shows how they compose into one button click.
Enrichment runs on a provider waterfall, not a single vendor
Data decays, so enrichment has to be the foundation, not an add-on. A contact record without a verified email or a current title is worthless for outbound.
Campaign Engine runs a provider waterfall: a primary enrichment data provider, with a secondary provider as a fallback and for specialty modes. The agent-facing tool exposes five enrichment modes that map to internal enrichment types:
• Full account enrichment (firmographics, tech stack, funding, web traffic).
• Full contact enrichment (title, LinkedIn, work history).
• Find email / find phone (specialty lookups with multi-provider waterfall).
• Verify email, before we ever risk a send.
• Check job change, for identity freshness.
Two engineering decisions matter here. Enrichment is conservative by default: write-back logic only fills blank fields unless you explicitly opt into overwriting, and we pre-load the existing record before writing so enrichment never clobbers good data with worse data. That is the difference between enrichment that builds trust and enrichment that quietly corrupts a CRM.
The second decision is sequencing. Modes run sequentially per contact, not in parallel, specifically to avoid write-back race conditions on the same row. Correctness beats a few hundred milliseconds.
Enrichment itself runs as async BullMQ jobs. An "enrich all" job scans for records missing data and fans out per-record jobs, each of which stamps a `workflow_executions` row as success, failure, or skipped, so the whole run is auditable. Enrichment is where most tools cut corners. Treating it as a first-class, idempotent, auditable pipeline, rather than a synchronous side effect, is what lets us enrich at volume without degrading the CRM we're supposed to be enhancing.
Propensity scoring splits research from judgment across two model classes
Not every account deserves the same attention, and a score with no reasoning behind it is not usable. Every account gets scored 1–100 against research signals the customer defines: ICP fit, funding, hiring, tech adoption, churn or upsell risk, or arbitrary custom prompts.
Each signal runs in two distinct phases, deliberately using different model classes for different jobs. The research phase is a live web-research call through Perplexity that returns an answer plus citations: the "go find out what's true about this company right now" step. The scoring phase is a structured LLM call, via the AI SDK, constrained to a strict schema of `{ score: number, reasoning: string }`. The research answer is interpolated into the scoring prompt, the score is clamped to a valid range, and we retry up to three times on schema-validation failures.
Each signal writes three fields back to the account: `{signal}_research` (the raw JSON), `{signal}_reasoning` (the explanation), and `{signal}_score` (the number). A final Overall Account Propensity step then synthesizes all the per-signal scores into a single weighted score with reasoning, a separate AI call whose prompt references every signal's score and reasoning. The full workflow: get record, run each signal, synthesize overall score, write back.
We split research and scoring into two model classes because they are different problems. Web research wants recency and breadth. Scoring wants determinism and structure. Forcing both through one model call produces mush. Splitting them, and constraining the scoring output to a schema with retries, is how we get scores that are both explainable and machine-reliable.
Identity resolution makes it safe to connect to your CRM
The same human shows up as multiple records, and if you don't resolve identity, you email the same person four times. One record comes from Salesforce, one from HubSpot, one from enrichment, one from a form fill. Without resolution, analytics lie and CRM sync loops forever creating duplicates. This is the least glamorous system we built, and the most important.
Our solution centers on a canonical contact. Every contact row has an `is_canonical` flag, and matching resolves to it. Email, lowercased, is the identity key. If an incoming contact has a CRM ID, we match on email plus CRM ID; if a record with that CRM ID exists, we resolve to the canonical contact for that email. The first contact seen with a given email becomes canonical, and contacts without an email are never canonical. When a match exists but no canonical has been designated yet, the incoming record gets promoted.
Multiple source rows can share an email, but exactly one is the canonical identity, and every lookup resolves to it.
Associations (contact to account, contact to opportunity, activity to contact, and so on) live in junction tables with conflict-safe upserts batched at 1,000 rows, using `onConflictDoNothing` and `onConflictDoUpdate`. Re-syncing the same data never creates duplicate edges. The graph is idempotent.
Identity resolution is what makes "built from data you already own" actually safe. Without it, connecting to your CRM is a liability. With it, we dedupe across providers, associate activity to the right person, and guarantee one human gets one coherent outreach experience.
Activity tracking splits first-party engagement from provider delivery data
Everything a campaign does lands in a single activities table, joined to contacts, ingested through two separate paths on purpose. Opens and clicks are first-party: we use our own open-pixel and click-redirect rather than trusting the sending provider. Each engagement writes an activity (`EMAIL_OPEN` / `EMAIL_CLICK`) with a unique source ID, so we can record multiple engagements per send. We dedupe scanner double-fires within a short window, and a click implicitly records an open.
Delivery lifecycle comes from our native sending engine via webhooks: sent, delayed, held, delivery_failed, bounced. Each event matches to a send by a globally unique message ID and stamps denormalized columns for delivery status, sent-at, bounce type, and error. Bounces and failures write a timeline activity with a deterministic source ID plus `onConflictDoNothing`, so provider retries can never duplicate a record. A hard bounce automatically removes the contact from the sequence.
The webhook handler is best-effort and never throws, because if it throws, the provider retries, and retries on a non-deterministic handler create duplicates. Idempotency is a design constraint, not an afterthought.
We split the two paths because provider-reported engagement is inconsistent and we want the pixel and redirect under our own control, while delivery state has to come from the provider, since that is the only source of truth for what the mail server actually did. Merging both into one activity model, with idempotent writes, is what lets analytics and triggers run off a single stream.
AI orchestration keeps generation cheap, fast, and cost-visible
A single button click only works if the AI behind it is fast, cheap, and reliable at scale, and three systems make that true.
The first is model routing through a gateway with fallback. Every LLM call goes through the Vercel AI Gateway. We configure provider preference order per model family through environment config, with strict "only" variants when we need to pin a provider. Fallback models are packed into the request, and we record which model was requested versus actually served, and whether a fallback fired. When a provider degrades, we route around it without a deploy.
The second is generate-once, reuse-per-step. The naive design generates each campaign step with its own LLM call. For a five-step sequence across thousands of contacts, that is a cost and latency problem. Instead, we generate all AI steps of a sequence in a single structured call. We build a per-channel schema (email needs subject plus body, a call needs a script, a voicemail needs its own shape), merge them into one object schema, and make one `generateObject` call that returns every step at once. The generation spec persists onto the campaign config, so subsequent per-contact renders reuse it instead of re-calling the model per step. To be precise: this is generate-once-and-store, not runtime token caching. The win is collapsing N calls into one and persisting the result, which is cheaper, faster, and produces more internally consistent copy across a sequence.

The third is cost as a first-class, per-call metric. We convert token usage to credits on every call. We fuzzy-match the model against a pricing table, normalizing snapshot date suffixes, compute the dollar cost from per-million input and output rates, and convert to credits, with a flat per-operation fee layered on top where appropriate. Because pricing lives in a table keyed by model, we can run cheapest-first model comparison in the preview-and-refine loop, showing the user what each model would cost per contact before they commit to a send.
If you can't see the cost of every AI call, you can't run AI at scale. You just run it until the bill scares you. Making cost a per-call, per-contact number turns AI from an unbounded liability into a budgeted line item the customer controls.
Deliverability is infrastructure, not a setup step
The best email to the best contact is worthless in spam, so sending is treated as infrastructure, not a checkbox. Our native sending provider selects a mailbox per send by priority, respecting a warmup ramp (effective daily limits that grow over time) and per-mailbox daily send counts. Over-limit sends are throttled, and we emit a `campaign.mailbox.throttled` metric when that happens.
The provider layer is pluggable. A registry maps our native sending engine and several external sequencing tools to a common interface, so we can drive external sequencers with the same orchestration that runs our native send.
Per-org queues keep one customer's volume from starving another
All async work runs on BullMQ backed by Redis, deployed on Render, and the interesting design choice is dynamic per-org queues. A controller process holds a map of active workers and runs a meta-worker that watches for new queues appearing in a registry. Workflow and campaign-send queues are named per org, for example `workflow-{orgId}-{priority}`, and each org's concurrency is resolved from its own configuration, with cluster-wide caps enforced via global concurrency.
We built it this way for fairness and blast-radius isolation. One customer running a million-contact backfill can't starve another customer's real-time triggers. Failed jobs route to a dead-letter queue and the execution record is reconciled, so nothing silently disappears.
Enqueuing workflows goes through a dedicated queue controller service over HTTP, with W3C trace context injected on the way in, so a trace started in the app follows the job all the way through the worker.
Compilation and triggers are what turn one click into a running campaign
Everything above exists to support one action: a go-to-market person clicks a button, and a correct campaign starts running. When you author a sequence, we compile it into a native campaign and its steps. Compilation rewrites the templating namespace, stores the workflow ID and inbox IDs, persists enrichment and tracking config, and stashes the combined-generation spec so the AI generator knows how to produce content later. Re-compiling the same sequence is idempotent: we locate the existing mirror via a JSON filter and update it in place.
CRM events, record created or updated, activity created, opportunity associated, flow into a trigger processor. It loads cached active triggers, evaluates each (lazy-loading full record data only when a filter needs a field that didn't change), and fans out in two isolated directions: workflow execution and campaign auto-enrollment. Campaign rules reuse the exact same filter engine as workflow triggers, and contact-selection filters narrow which associated contacts actually enroll.
That isolation is deliberate. A campaign-rule failure can never break workflow processing, and vice versa. Errors are swallowed and recorded on the span rather than propagated. Enrollment then enqueues per-contact send-step jobs on that org's queue, which render content (templated or the cached AI generation), pick a warmed mailbox, dispatch through the resolved provider, and stamp the execution record.
Observability makes the invisible failures visible before customers feel them
You can't operate what you can't see, so everything above emits OpenTelemetry traces and metrics to Honeycomb. The AI SDK is bridged to OTel so every LLM call emits a span with token usage, stamped with feature, user, and org IDs, so we can slice cost and latency by customer and feature. We record gateway routing outcomes (requested versus served model, fallback fired, attempt count) as span attributes. Custom metrics cover queue depth, wait time, mailbox throttling, and trigger processing (matched, enqueued, evaluation duration). Span attributes are namespaced under `gtm.*` so the whole system is queryable as one coherent dataset.
At this level of orchestration, the failure modes are subtle: a provider degrading, a queue backing up for one org, an AI cost spike on one feature. Uniform, per-org, per-feature telemetry is what makes those problems visible before a customer feels them.
The end-to-end picture
One button click wires up this chain: compile the sequence into a native campaign and steps, storing the generation spec and inbox config; trigger on CRM events, evaluating cached triggers and fanning out, isolated, to workflows and campaign enrollment; enroll the right contacts using the shared filter engine and contact-selection rules; send per-contact steps on that org's queue, rendering content through the combined-call AI generation, respecting warmup and daily limits, through the resolved provider; track engagement via first-party pixel and redirect plus delivery lifecycle via provider webhooks, idempotently; and meter and observe every hop, converting token usage to credits and writing `gtm.*`-namespaced spans and metrics to Honeycomb.
None of these systems is individually novel. What took real engineering is making them compose correctly: idempotent identity resolution so we never double-send, a provider waterfall so data stays fresh, generate-once AI so cost stays bounded, per-org queues so no customer starves another, and end-to-end tracing so we can actually operate it.
A go-to-market person describes an audience, clicks a button, and a correct, deliverable, fully-tracked campaign runs off the data they already own. The simplicity is the feature. Everything in this post is what it takes to earn it.
FAQ
What is Campaign Engine built on?
Campaign Engine is a pipeline of six stages: compile the authored sequence into an executable campaign, trigger on CRM events, enroll the right contacts, send each step through the right inbox, track what happened, and meter every step for cost and performance.
How does Campaign Engine keep enrichment from corrupting CRM data?
Write-back logic only fills blank fields unless a user explicitly opts into overwriting, and the existing record is pre-loaded before writing so enrichment never replaces good data with worse data. Enrichment modes run sequentially per contact to avoid write-back race conditions.
How does propensity scoring stay explainable?
Each signal runs a research phase (a live web-research call with citations) and a separate scoring phase (a structured LLM call constrained to a `{ score, reasoning }` schema). Each signal writes its research, reasoning, and score back to the account, and a final synthesis step produces one weighted overall score with reasoning.
How does Campaign Engine prevent duplicate contacts or duplicate sends?
Through identity resolution built around a canonical contact. Email, lowercased, is the identity key. Every source row that shares an email resolves to exactly one canonical identity, and associations are written with conflict-safe upserts so re-syncing data never creates duplicate edges.
Why track opens and clicks separately from delivery status?
Opens and clicks are first-party, captured through Campaign Engine's own open-pixel and click-redirect, because provider-reported engagement is inconsistent. Delivery lifecycle (sent, bounced, delayed, held) comes from the sending provider's webhooks, because that is the only source of truth for what the mail server did.
How does Campaign Engine control AI cost at scale?
By generating every step of a sequence in a single structured call instead of one call per step, and by converting token usage to credits on every call using a per-model pricing table. That makes per-contact cost visible before a send, including cheapest-first model comparison in the preview-and-refine loop.
Is deliverability handled by a third-party sending tool?
Campaign Engine's native sending provider selects a mailbox per send by priority, enforces a warmup ramp and per-mailbox daily limits, and throttles over-limit sends. The provider layer is pluggable, so external sequencers can run through the same orchestration.
Why does each organization get its own queue?
For fairness and blast-radius isolation. Workflow and campaign-send queues are named per org with their own concurrency limits, so one customer's large backfill can't starve another customer's real-time triggers.