From Zero to Outbound Campaign: A Recruiter Agent Walkthrough
Build an AI recruiter agent that voice-calls a candidate list with a per-call objective — persona, campaign, CSV audience, paced dispatch, and dispositions.
You have a list of 300 candidates who applied for a warehouse-supervisor role. Someone has to call each one, confirm they're still looking, check shift availability, and book the ones who qualify into a screening slot. That's a week of a recruiter's time on the phone — most of it spent on voicemail and "not interested."
This is exactly the kind of work an AI recruiter agent does well. In this walkthrough we build one end to end on Matrix: a voice agent with a recruiting persona, a campaign that points it at a candidate list, and a paced dialer that calls each person with their own per-call objective. By the end you'll have placed real outbound calls and watched dispositions roll up in the dashboard.
The scenario is fictional — call it Northwind Logistics, hiring warehouse supervisors — so swap in whatever you actually recruit for. Nothing here is specific to staffing; the same five steps run a renewal campaign, an event reminder, or a survey.
You'll need an operator login to your org's dashboard and a configured Exotel telephony provider (the outbound calling deep-dive covers that setup). Throughout, BASE is your backend and TOKEN is your operator JWT:
export BASE=https://your-backend.example.com
export SLUG=northwind # your org slug
Step 1 — Create the recruiter agent
The agent is the persona that does the talking. You can build it in the dashboard at /orgs/{slug}/admin/agents → New agent, or POST it. The persona, voice, and the tools it needs to log outcomes are all that matter for a campaign.
AGENT=$(curl -s -X POST $BASE/api/orgs/$SLUG/agents \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{
"properties": {
"agentKey": "northwind-recruiter",
"name": "Riya — Northwind Recruiting",
"systemPrompt": "You are Riya, a friendly recruiter for Northwind Logistics. You call candidates who applied for warehouse-supervisor roles. Confirm they are still job-seeking, check shift availability and notice period, and answer questions about the role. Be warm, concise, and respect a no. Always log the outcome before the call ends.",
"channels": "VOICE_REALTIME",
"voice": "Leda",
"voicebotEnabled": "true",
"providerKey": "<your-provider-id>",
"requiredCallerFields": "noticePeriod,shiftPreference"
}
}')
AGENT_ID=$(echo "$AGENT" | jq -r .id)
echo "Agent id: $AGENT_ID"
A few fields are doing real work here:
voice— one of the eight prebuilt Gemini Live voices:Aoede, Charon, Fenrir, Kore, Puck, Orus, Leda, Zephyr.voicebotEnabled— turns on the telephony bridge so the agent can take a phone line.requiredCallerFields— a CSV of contact fields the agent should learn. Matrix renders a "what you still need to learn" checklist into the prompt and saves answers via the built-in memory tools, so the agent doesn't re-ask things it already knows.
The tools that log outcomes are free. Every agent gets the five memory built-ins automatically — update_contact_profile, add_contact_note, and friends — so Riya can persist a candidate's notice period or shift preference the moment she hears it. And when the agent runs inside a campaign, it also gets a structured-disposition write so the call's outcome lands on the right CampaignContact row. You don't wire either of those up; they're composed into the agent's tool surface for you. (If you run Matrix as your pipeline, the dynamic update_lead / update_contact tools adapt to your custom fields too — see Matrix as your CRM.)
Tip:
close_conversationis a universal voice tool — the agent says goodbye and hangs up the call itself, so you don't get dead air after the conversation ends.
Step 2 — Create the campaign
A campaign binds an agent to an audience plus per-campaign instructions — the objective every call pursues. Create it as a DRAFT:
CAMPAIGN=$(curl -s -X POST $BASE/api/orgs/$SLUG/campaigns \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d "{
\"name\": \"Warehouse Supervisor — Screening Round 1\",
\"description\": \"Confirm interest + book screening slots\",
\"agentId\": $AGENT_ID,
\"channel\": \"VOICE_REALTIME\",
\"instructions\": \"Confirm the candidate is still interested in the warehouse-supervisor role. Verify they can work rotating shifts and ask their notice period. If they qualify and are interested, offer to book a 20-minute screening call this week and capture a preferred day. Log the disposition before hanging up.\",
\"maxConcurrent\": 1,
\"callsPerMinute\": 4
}")
CAMPAIGN_ID=$(echo "$CAMPAIGN" | jq -r .id)
echo "Campaign id: $CAMPAIGN_ID"
instructions is the key field. At call time it becomes a dedicated ## Your objective on THIS call block layered onto the agent's persona — for that call only. Riya stays Riya; the campaign just tells her what to raise. The block sits after the persona and before the memory context, early enough to be authoritative for the call without overwriting who the agent is. Calls placed outside a campaign get the exact prompt they always did.
Note the two throttle knobs:
maxConcurrent— how many calls run at once (capped by your org's concurrency limit).callsPerMinute— an average-rate ceiling.
We set maxConcurrent: 1 deliberately — more on why in the caveats. Pace conservatively to start.
Step 3 — Load the audience from a CSV
You rarely target candidates by filter; you have a list. Matrix imports a CSV or Excel file of contacts and preserves the per-contact context — name, the role they applied for, anything else — straight into each call.
In the dashboard, open the campaign's detail page and upload your file. Each column you don't map to a phone number is kept as context on that contact's row. A candidate file might look like:
phoneNumber,name,roleApplied,city,source
+15550100001,Marcus Bell,Warehouse Supervisor,Columbus,Indeed
+15550100002,Dana Ortiz,Warehouse Supervisor,Toledo,Referral
+15550100003,Sam Whitfield,Warehouse Supervisor,Dayton,Career fair
When Riya calls Marcus, his name, the role he applied for, and his city are available to her — so the call opens with "Hi Marcus, this is Riya from Northwind about the warehouse-supervisor role you applied for in Columbus," not a cold "who is this." That context rides alongside the campaign objective into the prompt.
If you'd rather select an existing audience programmatically, the campaign API also takes a structured filter (status, city, language, free-text) or pasted phone numbers. Preview the match count before committing:
curl -s -X POST $BASE/api/orgs/$SLUG/campaigns/audience-preview \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"city":"Columbus","status":"ACTIVE"}' | jq '{count, sample}'
Whichever way you choose the audience, it gets snapshotted at Start — the list is frozen, so editing it later doesn't change a running campaign. To target newly added candidates, create a new campaign.
Step 4 — Start it and watch the dialer pace itself
Now the fun part. One POST flips the campaign live:
curl -s -X POST $BASE/api/orgs/$SLUG/campaigns/$CAMPAIGN_ID/start \
-H "Authorization: Bearer $TOKEN" | jq '{status, totalCount}'
Here's what happens under the hood, in order:
CampaignService.startresolves the audience and snapshots oneCampaignContactrow per candidate, eachPENDING. The campaign flips toRUNNING(orSCHEDULEDif you set a futurescheduledFor).CampaignScheduler— a scheduled pacer that ticks every few seconds — wakes up, computes a per-tick budget frommin(concurrency budget, rate budget), and dispatches that manyPENDINGcontacts. Concurrency budget iscap − in-flight; rate budget keeps the running average undercallsPerMinute. It dispatches, then waits for the next tick. No thundering herd.- Each dispatched contact runs through the task runtime to
OutboundCallService.place, which dials via Exotel and creates the call'sSessionrow — stamped with the campaign objective (taskInstructions) and that contact's context. - When the candidate answers, the
CallSessionbridge opens a stream to Gemini Live and assembles the prompt: persona + the "Your objective on THIS call" block (built from your campaign instructions + this contact's context) + the agent's memory of the contact. Riya starts talking.
The contact moves PENDING → DISPATCHED → IN_PROGRESS as it goes, and the cap reflects in-flight calls — a slot frees only when a call actually ends, not when a worker thread returns. That's what keeps a low-concurrency telephony account from being overrun.
Step 5 — Track results
As calls complete, two things write outcomes:
- The agent logs a structured disposition before hanging up — "interested, booked screening," "not looking," "wrong number" — onto the
CampaignContactrow. - Exotel's status callback hits Matrix when the call terminates and rolls the contact into a terminal state:
COMPLETED,FAILED, orNO_ANSWER(no-answer and busy both map here). That bumps the campaign'ssuccessCount/failureCountcounters, and when the last contact drains the campaign flips toCOMPLETED.
Watch it live on the campaign's detail page. The Overview tab shows a status funnel, a disposition breakdown, and the current dispatch rate, polling while the campaign runs. The Leads tab is the per-contact table — sortable, with inline disposition edits if a human needs to correct an outcome. You can also pull progress over the API:
curl -sH "Authorization: Bearer $TOKEN" \
"$BASE/api/orgs/$SLUG/campaigns/$CAMPAIGN_ID/progress" | jq \
'{total, success, failure, pending, inFlight, dispositionCounts}'
Everything Riya learns on a call — notice period, shift preference, the booked slot — also persists to the candidate's contact record as memory, so it's there for the next conversation, on any channel.
Two operational caveats — read before you scale
These aren't edge cases; they bite real campaigns.
Keep the backend at a single instance during a run. CampaignScheduler lives in every backend instance, and each tick reads the same PENDING rows. If your platform scales to two instances mid-campaign, both tick, both read the same pending contacts, and both dispatch — each dispatch is a distinct task, so idempotency won't catch it, and the same candidate gets called twice. On Cloud Run, keep the backend pinned to min-instances=1 while a campaign is live (it's the documented constraint until a Neo4j-level dispatch claim lands).
Pace to your telephony account's real streaming concurrency. Voice campaigns hold a live media stream per call, and some accounts allow only a single concurrent stream. If maxConcurrent exceeds what your account can stream, calls fail to connect — not a platform bug, a provider cap. Start at maxConcurrent: 1, confirm a clean call, and raise it only as far as your account allows.
Takeaway
An outbound voice campaign on Matrix is five steps: create the agent, create the campaign, load the audience, Start, track results. The persona stays generic and reusable; the per-call objective and each candidate's context are injected at dial time, so the same recruiter agent can run a screening round today and a re-engagement round next week with nothing more than new instructions and a new list. The dialer paces itself, the agent logs its own dispositions, and the dashboard rolls them up.
Smoke-test one outbound call first (POST /api/orgs/{slug}/telephony/calls), pin your backend to one instance, set maxConcurrent: 1, and start small. Then scale the list, not the risk.
Ready to build yours? Open /orgs/{slug}/admin/agents, stand up a recruiter persona, and point a campaign at your candidate list. Pair it with Matrix as your CRM to turn every disposition into a tracked lead, and read the outbound calling deep-dive for the Exotel setup and pacing internals.
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.