All articles
Voice·January 25, 2026·9 min read

Outbound AI Calling Campaigns That Don't Sound Like Robocalls

Run outbound voice campaigns with a real agent — memory, per-call objective, tools — instead of a dumb dialer. Here's the full pipeline and the one hazard to know.

By Matrix Team

There are two ways to do outbound voice at scale. One is a dialer: a list of numbers, a prerecorded script, a "press 1 to speak to an agent." The recipient knows inside two seconds it's a robocall, and the only metric that moves is the hang-up rate.

The other way is to put a real agent on every call — one that knows who it's calling, why it's calling, what it learned the last time it talked to this person, and what counts as success on this call. It greets the contact by name, pursues a specific objective in its own voice, writes the outcome down, and hangs up like a person would. Same outbound motion. Completely different conversation.

Matrix does the second one. An outbound campaign is just one of your existing agents — persona, memory, tools, knowledge all intact — pointed at an audience with a per-campaign objective. This post walks the whole pipeline, from POST /campaigns to the disposition that lands on the contact's record, and flags the one operational gotcha you need to know before you scale.

The shape of a campaign

A campaign is an operator-defined outreach run. You pick:

  • An agent — any agent in your workspace. A recruiter, an admissions counsellor, a retention rep. It keeps its persona; the campaign just gives it a job for these calls.
  • An audience — contacts, selected three ways (more on that below).
  • Per-campaign instructions — the objective layered onto the agent's persona for these calls only.
  • Pacing — how many calls can be in flight at once, and the average rate.

Then you hit Start, and the platform fans out one agentic task per contact, paces them, places the calls, injects the objective, and tracks every outcome back to the contact's record.

Here's the create-and-start, end to end:

POST /api/orgs/{slug}/campaigns
{
  "name": "Consultation reminder calls",
  "agentId": 123,
  "channel": "VOICE_REALTIME",
  "instructions": "Greet the contact, confirm their upcoming consultation, and remind them which documents to keep ready.",
  "audience": {
    "status": "ACTIVE",
    "city": "Pune",
    "preferredLanguage": "hi-IN"
  },
  "maxConcurrent": 3,
  "callsPerMinute": 6
}
POST /api/orgs/{slug}/campaigns/{id}/start

That's it. The first call creates the campaign in DRAFT. The second one snapshots the audience and turns the pacer loose. Everything from here is the platform working through your list.

The pipeline, step by step

When you hit /start, a chain of services takes over. Each one does one thing and hands off.

1. Snapshot the audience

CampaignService.start resolves your audience filter into a concrete list of contacts, drops any that lack a phone number (you can't call a number that isn't there), and writes one CampaignContact row per contact in status PENDING. The campaign flips to SCHEDULED or RUNNING with totalCount set.

The word snapshot is load-bearing: the audience is frozen at start time. Adding or removing contacts later doesn't change a running campaign. If you need to reach newly added contacts, you start a new campaign — the old one keeps working through the list it captured.

2. The scheduler dispatches, paced

CampaignScheduler is a @Scheduled bean that wakes up on a tick (default every 5 seconds). On each tick, for each running campaign, it computes a dispatch budget from two limits and combines them:

  • Concurrency capmin(campaign.maxConcurrent, orgCap), minus the calls already in flight. A contact counts as in flight from the moment its call is placed until the carrier's status callback marks it terminal, so the cap reflects live calls, not queue depth.
  • Average ratecallsPerMinute × (minutesSinceStarted + 1) must stay ahead of how many you've already dispatched. A loose token-bucket-ish bound that clamps the running average.

It dispatches up to min(concurrencyBudget, rateBudget) of the next PENDING contacts, marking each DISPATCHED with a task id. This is what keeps a thousand-contact campaign from machine-gunning the carrier (and from blowing past your provider's concurrency limit) — it trickles calls out at a human pace.

3. Place the call

Each dispatched contact rides the task bus to AsyncTaskRuntime.handleCampaignStep, which calls OutboundCallService.place. That mints an outbound session token, resolves the agent's telephony provider, and asks Exotel to dial via /Calls/connect. It also writes a Session row — the unified interaction entity for chat and voice — carrying two campaign-specific fields:

  • campaign — a reference back to the campaign.
  • taskInstructions — the per-call objective, copied onto this exact call.

The contact flips to IN_PROGRESS. Note the subtle bit: at this layer, "run completed" means the call was placed, not that it ended. The call's actual lifecycle is tracked separately, on the CampaignContact.

4. The agent runs — with this call's objective baked in

When the contact answers, a CallSession bridges the carrier audio to Gemini Live. Before the first word, it reads taskInstructions off the Session row and the agent's setup prompt is assembled:

fullPrompt = systemPrompt
           + TELEPHONY_RIDER
           + objectiveBlock     ← "## Your objective on THIS call\n{instructions}\n
                                    Pursue this objective naturally, in your persona's own
                                    voice and language. It guides what you raise on this call;
                                    it does not change who you are."
           + now block
           + universal objectives
           + skill blocks
           + linguistic block
           + memory context

This is why the calls don't sound like robocalls. The objective is positioned after the persona and before the memory block — early enough to be authoritative for this call, but it explicitly does not overwrite who the agent is. The recruiter is still the recruiter. The campaign just tells her what to raise today. And because the memory context follows, the agent walks into the call already knowing what it learned about this contact on the last one.

When the objective is blank — a non-campaign call placed through the REST endpoint, or an inbound call — that block renders empty, so those calls get exactly the prompt they always did. The campaign machinery is additive, not a fork.

5. The outcome lands on the record

When the call ends, two things happen. The post-call memory extractor distills the conversation into a session digest plus durable facts about the contact — so the next call is smarter. And Exotel posts a status callback that TelephonyWebhookController maps onto the CampaignContact:

completed         → COMPLETED
no-answer | busy  → NO_ANSWER
failed            → FAILED

Then it rolls up the campaign counters and, when the last contact drains, flips the campaign to COMPLETED. The full status flow:

PENDING ──scheduler──▶ DISPATCHED ──runtime──▶ IN_PROGRESS ──status callback──▶ COMPLETED
                                                                              ├▶ FAILED
                                                                              └▶ NO_ANSWER

Picking who to call — and carrying their context

Audience selection has three modes, first non-empty wins:

  1. Explicit ids — hand-picked contacts from a multi-select.
  2. Pasted phone numbers — CSV or newline-separated, normalised on the last 10 digits so +91XXXXXXXXXX, 0XXXXXXXXXX, and the bare number all match the same contact. Unknown numbers still get dialled.
  3. Structured filters — status, city, occupation, preferred language, marketing consent, free-text search.

You can preview before you commit: audience-preview runs the filter and returns the count plus a sample, which is what the create form's "Preview audience" button calls.

Beyond filters, you can import an audience from CSV or Excel — and the import preserves arbitrary per-contact context. That context rides along into the per-call objective, so the agent doesn't just know it's calling someone in Pune — it knows the application id, the role they applied for, the renewal date, whatever you put in the spreadsheet. That's the difference between a personalized call and a mail-merge.

Writing the outcome down: disposition tracking

A campaign isn't useful if you can't tell what happened on each call. Two things capture that.

The carrier status callback gives you the coarse outcome (COMPLETED / FAILED / NO_ANSWER) for free. But the meaningful outcome — "interested, wants a callback Tuesday," "already renewed," "asked to be removed" — comes from the agent itself, through the built-in update_campaign_contact tool. The agent writes a structured disposition during the call, so the result on the record is what the conversation actually produced, not just whether the phone was picked up.

Operators can fix up a disposition by hand too, and the campaign's Overview tab shows a live funnel and disposition breakdown while the campaign runs.

The task bus: in-process by default, Kafka by config

The fan-out runs over a pluggable task bus. By default it's in-process — an in-JVM queue and a single daemon thread, publish and consume in the same process. That's the production default and it's plenty for the volumes campaigns run at. No broker to stand up, no extra moving part.

Flipping to Kafka is a config change, not a code change: set the backend property and point it at a broker. The campaign code path is identical either way. The seam is there for when you need it; you don't pay for it until then.

The one hazard: don't scale mid-campaign

This is the part to internalize before you run a real campaign.

CampaignScheduler is a @Scheduled bean, which means it lives in every backend instance. At one instance, there's exactly one ticker, and everything above is correct. At two or more instances, both schedulers wake on the same tick, both read the same PENDING rows, and both dispatch. Each dispatch is a distinct task, so the idempotency layer doesn't catch it — and the same contact gets called twice.

This has nothing to do with Kafka vs. in-process; it's a property of the scheduler running everywhere. The practical rule is simple: keep the backend at min-instances=1 during a campaign run. The eventual fix is a database-level claim — a conditional update so only one instance wins each contact — but until that ships, don't let your backend autoscale while a campaign is dialing.

What's not built yet

One honest seam: WhatsApp. The runtime branches on channel, and everything non-voice fails fast today. The data model already carries a channel discriminator and the dispatch path has a clean place to slot it in — but the send path, the inbound webhook, and WhatsApp's 24-hour-window and template rules aren't written. Voice is what ships. WhatsApp is a marked seam, not a feature.

Takeaway

An outbound campaign in Matrix is your existing agent — with its persona, its memory, and its tools — pointed at an audience with a per-call objective. The pipeline is: snapshot the audience into PENDING rows, pace dispatch against concurrency and rate caps, place each call with the objective on the Session row, inject that objective into the agent's prompt so the call is personalized but still itself, and roll each outcome back onto the contact's record. The conversations sound human because there's an actual agent on the line, not a script. Just remember to keep the backend at a single instance while it runs.

For a hands-on build — wiring up an agent, an audience, and a live campaign from scratch — see From Zero to Outbound Campaign: A Recruiter Agent Walkthrough. And to understand the two ways the same agent talks over voice in the first place, read One Agent, Two Voice Paths: Telephony Bridge vs. Browser-Direct.

Ready to run one? Create a workspace, build an agent, and point it at a list. The full design — data model, pacing, instruction injection, Exotel setup — is in docs/CAMPAIGNS.md.

#ai outbound calling#campaigns#voice agent#telephony

Build your first agent on Matrix

Spin up a workspace, wire up tools and knowledge, give your agent a voice, and talk to it in real time — no agent code required.

Keep reading