Campaigns
Campaigns are an agent-scoped operating layer for running outbound calling programs and attributing inbound calls, on top of the same primitives every other Rymi call uses: agents, the Calls API, post-call intelligence, DNC, credits, and webhooks. A campaign never becomes a separate voice runtime — it coordinates existing pieces and adds strategy, retries, follow-ups, reporting, and improvement suggestions around them.
This guide covers the concepts. For the endpoint reference, see Campaigns API and Contacts API.
Campaigns vs. Group Call Fanout
Rymi has two different ways to reach many phone numbers, and they solve different problems.
Group call fanout (POST /v1/calls/fanout, with POST /v1/calls/batch as its compatibility alias) creates one call session with many participants — a single room, multiple PSTN legs joined together. Use it when you want several people in the same live call (e.g. dialing a group into one conference-style session).
Campaigns create many independent call attempts — one calls row per recipient, each with its own room, transcript, recording, retry outcome, and report contribution:
Group call fanout: one calls row · one room · many PSTN participants
Campaigns: one campaign → many attempts · one calls row per attempt · one recipient per callIf you're running a sales or outreach program where each recipient needs their own private conversation, independent retries, and per-recipient follow-ups and reporting, use Campaigns. If you need multiple people on one live call, use fanout. The two are unrelated features — a campaign never uses the batch/fanout endpoint internally; every attempt is a normal, independent createCall().
Contacts vs. Campaign Members
A contact is a tenant-level person or lead: identity, consent, and history live once, no matter how many campaigns reach out to them. A campaign member is that contact's state within one campaign — status (ready, queued, succeeded, suppressed, …), attempt count, next attempt time, and per-campaign variable overrides.
The same contact can belong to many campaigns simultaneously, each tracking independent progress. Import contacts once via POST /v1/contacts/import, or import directly into a campaign with POST /v1/campaigns/:id/members/import, which creates/merges the underlying contacts and attaches them as members in a single call.
Outbound vs. Inbound Campaigns
Outbound campaigns dial a list of members on a schedule. A background scheduler sweeps for due members every 30 seconds, claims a batch, and for each member runs one normal createCall() — quota, credit, DNC, and kill-switch checks apply per attempt, exactly as they would for a manual call.
Inbound campaigns are route attribution, not queueing. You bind one or more owned numbers to the campaign as inbound routes; when a call arrives on that number, it's routed to the campaign's agent and tagged with campaign_id in call metadata — a normal inbound call, just attributed to the campaign for reporting. Only one active route is allowed per number; if none is active, the number falls back to its normal agent assignment.
The Goal Model
Every campaign declares what "success" means, evaluated against the call's post-call structured extraction:
{
"goal_type": "book_meeting",
"success_field": "meeting_booked",
"success_when": { "equals": true },
"secondary_fields": ["callback_requested", "not_interested", "wrong_number"],
"human_label": "Book a qualified meeting"
}At call creation, the scheduler merges the campaign's goal fields into the agent's post_call_config (the agent's own extraction fields are preserved; the campaign's are added). After the call, the goal predicate — equals, not_equals, exists, gte, lte, or in — is evaluated against call_intelligence.structured_data to set the attempt outcome. A call that never produced meaningful user content (skip_reason: "no_user_content") never counts as a success, regardless of the predicate.
Snapshot Behavior — Track, Don't Pin
Calls always serve the agent's current published snapshot — no runtime path serves an older one. What campaigns add is attribution: the campaign records agent_snapshot_id at launch, and each attempt stamps the snapshot actually served. This lets the report segment outcomes by snapshot (by_snapshot), so you can see whether a mid-campaign republish helped or hurt conversion. If you edit and republish the agent while a campaign is running, Studio shows a warning banner — true pinning (serving an older snapshot on purpose) is a larger runtime change and isn't built yet.
Retries
Each campaign has a retry_policy (max_attempts, backoff schedule). Retryable outcomes — no_answer, busy, dial failure — put the member back to ready with next_attempt_at set by the policy-defined backoff (e.g. 2 hours, then 24 hours) as long as attempt_count < max_attempts; otherwise the member becomes exhausted. If the caller requests a callback at a specific time, that overrides the normal backoff.
Voicemail Handling
The voice runtime detects answering machines on turn one and, when the agent has a voicemail_script configured, either hangs up or drops a short message. Campaigns record these as a distinct voicemail outcome — separate from answered/completed — so answer-rate and goal-conversion metrics stay honest even when a chunk of dials hit machines. retry_policy.retry_on_voicemail (default true) controls whether a voicemail retries like a no_answer (subject to max_attempts and backoff) or is terminal. If the agent has no voicemail_script, the runtime can't detect machines at all, and voicemails run as full conversations — the launch-readiness check surfaces this as a warning (not a blocker), since some campaigns deliberately talk to machines.
Follow-Ups
A campaign's automation_policy defines what happens after an attempt resolves — across all channels, each gated by consent and capability:
| Kind | What it does | Gate checked at send time |
|---|---|---|
call | Schedules another normal call attempt | DNC + voice consent |
sms | Sends via the tenant's telephony SMS provider | SMS consent + DNC |
whatsapp | Sends via the connected WhatsApp transport (approved template required) | Connector connected + template synced + WhatsApp consent |
telegram | Sends via the tenant's Telegram bot connector | Chat mapping exists + consent |
webhook | Fires campaign.followup.due via your registered webhook | Webhook registered |
human_handoff_task | Creates a notification for a human assignee | — |
mark_dnc | Adds the contact to the DNC registry and marks the member opted_out | — |
{
"on_no_answer": { "type": "call", "delay_minutes": 120, "max_attempts": 2 },
"on_callback_requested": { "type": "call", "delay": "caller_requested_time", "max_attempts": 1 },
"on_success": { "type": "whatsapp", "template_id": "confirmation", "requires_consent": true }
}Mid-call opt-out is built in — no policy needed. When a caller asks to stop being called, the agent's post-call extraction flags it (dnc_requested) and the platform automatically adds the number to your Do-Not-Call registry, opts the member out, and zeroes their consent. You don't wire this up; it always runs.
Consent and capability are checked at execution time, not at schedule time — consent can change in the interval. If a tenant doesn't have the connector or template a follow-up needs, the job fails with capability_missing and is visible in GET /v1/campaigns/:id/followups — it's never silently dropped. Quiet hours (by contact timezone) apply to outbound follow-up calls, SMS, and WhatsApp the same as first attempts: an out-of-window follow-up is deferred (re-tried every ~15 min) until the window opens, never sent at 2am.
All campaigns are clamped to a hard 08:00–21:00 contact-local calling window. Your schedule window can narrow it but never widen it — an empty schedule still means daytime-only, not 24/7.
Compliance
Read this plainly: the platform gives you the mechanisms — consent capture with evidence, a per-campaign policy, launch blockers that refuse to dial until the policy is satisfied, and a mid-call opt-out. It does not decide whether your configuration is legally sufficient for your market, and it does not integrate the US National DNC Registry or India's DLT/DND platforms — those need vendor contracts and are your responsibility with counsel. Every default here is a reasonable floor, not legal advice.
Consent capture. A contact's consent field accepts either a legacy bare boolean ({ voice: true }, granted with no evidence) or a structured evidence form ({ voice: { granted, at, source, method } }) that records when/how consent was obtained — the record-keeping TCPA-style "prior express written consent" needs. Record or revoke consent via PATCH /v1/contacts/:id/consent; every grant/revoke also appends an immutable row to the consent_events proof log. CSV/JSON import treats a granted consent column as a consent source too.
compliance_policy on the campaign (same convention as schedule_policy, retry_policy, etc.):
| Field | Values | Default | What it does |
|---|---|---|---|
require_consent | none | voice | all | voice | Which channels need granted consent before a first attempt |
require_consent_evidence | boolean | false | When true, granted-without-evidence isn't enough — the evidence form is required |
max_attempts_per_24h | integer | null | 3 | Per-member dial-frequency cap in a rolling 24h window; null = uncapped |
ai_disclosure | off | agent | attested | agent | agent requires the opener to disclose automation (best-effort phrase check); attested accepts a signed attestation instead |
dnc_scrub | none | internal | attested | attested | internal relies on the tenant DNC registry (always checked); attested additionally requires a current external-scrub attestation |
Launch blockers (checked by POST /v1/campaigns/:id/launch, returned in the response body either way):
- the agent has no current published snapshot
- the outbound campaign has no valid (non-suppressed, non-opted-out) members
- no caller ID / owned number is available (agent's assigned number for outbound; an active inbound route for inbound)
- one or more ready/queued members are on the tenant's DNC registry
- the tenant has insufficient credits
- the follow-up policy references a channel without its required connector connected
first_attempt_consent— blocks whenrequire_consentisn'tnoneand a ready/queued member lacks the required (and, ifrequire_consent_evidence, evidenced) consentdnc_scrub_current— blocks whendnc_scrubisattestedand no currentdnc_external_scrubattestation is on fileai_disclosure— blocks whenai_disclosureisagentand the opener doesn't disclose automation, orattestedwith no currentai_disclosureattestationvoicemail_handling— a warning (never blocks) when the agent has novoicemail_script, so voicemails will run as full conversations
Attestations are append-only records via POST /v1/compliance/attestations (dnc_external_scrub or ai_disclosure, plus a note) and stay current for 31 days.
Runtime guards, enforced continuously while a campaign runs:
- per-tenant, per-campaign, and per-number concurrency caps, enforced at claim time and safe across scheduler replicas, plus a global cross-tenant ceiling (
CAMPAIGN_GLOBAL_MAX_CONCURRENCY) - the claim RPC re-checks voice consent and the per-24h frequency cap at dial time, not just at launch — a member added mid-run, or whose consent was revoked after launch, is never selected
- quiet hours by contact timezone
- DNC and opt-out suppression, re-checked on every attempt via the normal
createCall()path - idempotency keys on every attempt (
campaignId:memberId:attemptNumber) and follow-up job (attemptId:kind) — sweeps are crash-safe and re-runnable - auto-pause: three consecutive whole-sweep rejections (e.g. credits exhausted mid-campaign) automatically pause the campaign with a visible reason and notify the tenant, instead of grinding forever against a broken precondition; the campaign auto-resumes once a healthy sweep confirms the platform recovered
Reporting
Two layers, with no duplication of call or transcript data:
- Live counters — the campaign row's
stat_*columns (members, attempts, answered, completed, goal successes, callbacks, opt-outs, follow-ups sent, handoffs, duration, cost), incremented transactionally as attempts resolve. The Studio header and the report'ssummaryobject read these directly — O(1), never a table scan. - Detail report (
GET /v1/campaigns/:id/report) — SQL aggregates overcampaign_attemptsjoined tocalls/call_intelligence: outcome breakdown, sentiment breakdown, answer rate by hour, cost per goal success, top failure reasons, and outcome segmented by agent snapshot.
Per Rymi's house display rules: cost is shown in credits (1 credit = $0.01), never dollars, and durations are shown in minutes, never seconds.
Operational Limits
The scheduler respects three levels of concurrency headroom, computed from live in-flight counts so caps hold across API replicas: per-campaign (concurrency_policy.campaign_max), per-tenant (concurrency_policy.tenant_max), and a global cross-tenant ceiling (CAMPAIGN_GLOBAL_MAX_CONCURRENCY, default 40) that stops any single tenant from browning out the shared voice fleet. A sweep claims only as many members as the tightest of the three allows.
A campaign that auto-pauses (three consecutive whole-sweep rejections) sends the owning tenant a campaign.auto_paused notification with the reason. If the pause was caused by a transient platform outage (e.g. Redis down), the campaign auto-resumes the moment a healthy sweep runs again — the resume is scoped to campaigns the scheduler itself paused (never a campaign a user paused manually). Runtime health — running campaigns, in-flight attempts vs. the global ceiling, auto-paused count, and stuck-queued members — is available at GET /admin/campaign-health for dashboards and alerting.
The Improve Loop
POST /v1/campaigns/:id/improve runs a hybrid engine — cheap, deterministic detectors gate an LLM proposal writer, so there's no LLM cost unless something is actually wrong:
- Detectors are pure functions over the campaign's counters and cheap aggregates: an early-hangup rate above 40%, a large gap between the best- and worst-performing calling hours, goal conversion below threshold, a missing follow-up policy where callbacks are being requested, and similar signals. If nothing crosses a threshold, no suggestion is produced and no LLM is called.
- Proposal writer — for signals that do trigger, an LLM call (billed on the post-call lane) reviews the evidence plus up to 5 redacted sample transcripts and writes a concrete, schema-validated proposal: a rewritten opener, a schedule-window change, a retry-policy adjustment.
- Suggestions land in the campaign's suggestion list (
GET /v1/campaigns/:id/suggestions) with their evidence attached. Accepting a suggestion that edits the agent creates a normalagent_changesdraft — reviewed and republished like any other agent edit, never applied silently. Accepting a campaign-policy suggestion (e.g. a schedule change) patches the campaign directly.
Suggestions never mutate a live agent without your review.
Webhooks
Campaigns fire events through the same webhook system as the rest of the API — see Webhook Events → Campaign events for the full table and Webhooks for delivery, retries, and signature verification.
SDK and MCP
The Node and Python SDKs expose the full surface: rymi.contacts.*, rymi.campaigns.* (including nested rymi.campaigns.members.* and rymi.campaigns.routes.*). See the Campaigns API reference for every method.
The MCP server exposes campaigns as tools. Read tools (list_campaigns, get_campaign, get_campaign_report, list_campaign_attempts, list_campaign_suggestions, list_contacts) are always available. Write tools (create_campaign, import_campaign_contacts, launch_campaign, pause_campaign, resume_campaign, accept_campaign_suggestion) are gated behind the same RYMI_MCP_READONLY flag as the rest of the write surface. launch_campaign's tool description states plainly that it places real outbound PSTN calls at scale and incurs charges.

