Inside Anima's Voice Guardrails: TCPA, RND, and Time-of-Day, Server-Side
Three server-side gates run before any outbound voice call. Skip one and the call doesn't happen. Here's exactly what each check does, where it lives, and why we refused to make any of it client-configurable.
Outbound voice from an AI agent is the riskiest thing the agent can do. One bad dial loop hits the wrong list, lands in a TCPA class action, and the operator is out a six-figure settlement before the standup ends. We have watched this happen to two YC companies in the last six months. Both had the same root cause: voice provider gave them a working API, no compliance opinions, no checks at the boundary. They built the orchestrator. They thought the orchestrator would handle it. Until it didn't.
We refused to ship Anima voice that way.
This post walks through the three checks that run on every outbound voice call placed through POST /voice/calls, where each check lives in the codebase, what happens when each one fails, and why none of them are switches the customer's agent can disable.
The three gates#
When an Anima agent calls am voice place --to +14155550142 --consent-source business-relationship, the request hits apps/api/src/routes/voice-calls.ts. Before we ever reach the dialer, three middleware functions run in order:
- TCPA gate (
apps/api/src/middleware/tcpa-gate.ts) — requires aconsent_sourceassertion on every call. Missing or malformed → fail closed with HTTP 403. - RND check (Twilio Lookup against the FCC Reassigned Numbers Database) — flagged numbers never dial. 30-day Postgres cache means repeat numbers cost nothing.
- Time-of-day window — 8am to 9pm local time, computed from the destination's area code. State-stricter windows configurable per-org.
Plus a fourth, less glamorous one: per-tier daily call cap. Free tier gets zero outbound calls. Starter and Growth get tier-appropriate ceilings. If the agent has burned through the day's calls, the API returns HTTP 402 with an upgrade link. We added this not because it's clever but because the worst voice incident we've seen wasn't an attack. It was a buggy retry loop.
Why these are server-side, not SDK helpers#
The most common pushback we get from engineers evaluating Anima voice is: "Why are these baked in? Just give me the dialer and let me add the checks I need."
We hear the argument. We disagree with the conclusion. The argument is: I know my use case, I can handle TCPA myself, your gates will get in my way for legitimate edge cases.
The reality is: legitimate edge cases are rare, attackers and bad agents are not, and "I'll handle it in my code" is exactly how the two YC companies above ended up paying settlements. The gate that lives in your code is the gate the next jailbreak routes around. The gate that lives at the credential boundary is the gate the agent cannot reach.
So we made a call: voice gates are not an SDK feature, they are an API requirement. If you want to disable them, you cannot, because there is no flag to flip. You can configure stricter limits per org. You can never go below the floor. The floor is "this is not a TCPA violation."
TCPA gate: the consent_source contract#
The TCPA (Telephone Consumer Protection Act) makes it expensive to call US consumers without their consent. "Expensive" here means $500 to $1,500 per violation, multiplied by every call in the suit, with no statutory cap. The recent class-action math has been ugly: one defendant settled at $61M for ~480,000 calls.
Anima's TCPA gate does not try to determine whether your consent is actually valid. That is your responsibility, your contract with the recipient, your records. What Anima does is force you to declare it. Every outbound call must include a consent_source field. If it is missing, the call fails. If it is present, it is logged with the call record under that organization's audit trail.
The accepted values mirror the categories the FCC and FTC use:
// apps/api/src/middleware/tcpa-gate.ts
const VALID_CONSENT_SOURCES = [
"opt-in:web-form", // explicit checkbox on a form you control
"opt-in:double-opt-in", // form + email confirmation
"customer-initiated", // they called you first
"business-relationship", // existing customer, transactional purpose
"prior-express-consent", // signed agreement
];If the agent does not pass one, the CLI surfaces the error with a link to https://useanima.sh/trust/tcpa-dnc that explains what each value means and which is appropriate for which workflow. We chose to make this a field rather than an account-level setting because consent context is per-call, not per-account. The same agent on the same account might be calling existing customers (transactional, allowed) and cold leads (requires opt-in, different value).
RND check: the cache is the magic#
The FCC's Reassigned Numbers Database (RND) tracks phone numbers that have been disconnected and reassigned to a new subscriber. Calling the number under the assumption it still belongs to the old owner is a classic TCPA pitfall: the new owner did not give consent, so the call is illegal even if the original consent record was valid.
Twilio Lookup exposes RND as a paid API. At about $0.01 per query, hitting it on every call is real money. Worse, it is a network round-trip on the hot path. We solved both problems with the world's most boring optimization: a 30-day Postgres cache.
CREATE TABLE rnd_check_cache (
phone_e164 TEXT PRIMARY KEY,
is_reassigned BOOLEAN NOT NULL,
raw_response JSONB NOT NULL,
checked_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX rnd_check_fresh ON rnd_check_cache (checked_at)
WHERE checked_at > now() - INTERVAL '30 days';On a fresh number, we hit Twilio Lookup, store the verdict, return it. On a repeat within 30 days, we return the cached verdict and skip the API call entirely. For a typical voice-heavy operator dialing the same lead list twice, the cost amortizes to under a tenth of a cent per call.
The miss path is fail-safe in the sense that matters: if Twilio Lookup is down or returns an error, we do not silently let the call through. We return HTTP 503 to the agent with a message explaining the lookup failed. The agent can retry. The agent cannot "skip the check because the API was slow."
Time-of-day: the area-code trick#
Federal TCPA rules limit calls to between 8am and 9pm local time. Some states (Florida, Georgia, Indiana, Massachusetts, Mississippi, etc.) have stricter windows or specific Sunday rules. The challenge: what counts as "local"?
Most agent platforms either ignore this entirely or ask the customer to provide a timezone with each request. We took a different angle: derive the local timezone from the destination area code on our side. The mapping is well-established (the NPA-NXX directory) and we cache it permanently because it changes maybe once a year.
// apps/api/src/services/voice/time-of-day.ts
const tz = areaCodeToTimezone(destination.npa); // e.g. "415" → "America/Los_Angeles"
const localHour = new Date().toLocaleString("en-US", {
timeZone: tz,
hour: "numeric",
hour12: false,
}); // "14" if it's 2pm local
const allowed = localHour >= 8 && localHour < 21;The agent never has to think about this. The dashboard never has to think about this. The customer never gets surprised that "we tried to call a Boston number at 7am Pacific from California and it went through." It does not go through. It cannot go through. The check is in the credential boundary.
The hard cap and the daily call limit#
The two non-TCPA gates exist because the worst voice incident we have seen was not malicious. It was a for loop that should have been a forEach and dialed the same list 80 times in 90 seconds.
The per-tier daily call cap means: even if your agent's logic is broken, the blast radius is bounded. Free tier dials zero outbound (you cannot place outbound calls without a paid plan). Starter caps at 200 outbound calls/day, Growth at 1,500, Scale at 4,000. Hitting the cap returns HTTP 402 with a clear upgrade message. The customer can lower their cap further per-org. They cannot raise it past the tier ceiling without upgrading.
The per-meter hard cap (separate from the daily cap, configurable per-org per-meter) means: even within the daily allowance, you can wire alerts and auto-shut-offs at custom thresholds. We expose this via the dashboard so the customer's CFO does not get surprised by a $4,000 voice bill.
What this looks like from the CLI#
Here is the actual command, on a real terminal, with a real failure mode:
$ am voice place --to +14155550142 --tier basic
Error: Missing --consent-source. The TCPA gate requires you to assert how you obtained consent for this call.
Examples: --consent-source opt-in:web-form / customer-initiated / business-relationship
See https://useanima.sh/trust/tcpa-dnc for the full compliance posture.
$ am voice place --to +14155550142 --tier basic --consent-source business-relationship
✓ Call placed: call_4f7c2b
Call ID: call_4f7c2b
State: ringing
From: +14155550199
To: +14155550142
Tier: basic
Direction: OUTBOUND
Tail live updates with: am tail --filter voice --agent <id>
View in dashboard: https://console.useanima.sh/audit (search by callId)Notice the second command did not need to declare a timezone, did not need to query an RND service, did not need to check whether the tier permits voice. All of that ran server-side, under one API call. The agent's job is to place the call. The platform's job is to enforce the floor.
What this does not solve#
We are direct about what we have not built yet.
National DNC Registry compliance is the customer's responsibility, not ours. This mirrors the standard Twilio/Telnyx/Vonage/Plivo posture: the carrier provides the infrastructure, the customer scrubs against the DNC list. We document this clearly at /trust/tcpa-dnc and the ToS Section 11 is explicit about the indemnification.
State-stricter rules beyond time-of-day windows (e.g. specific abandonment-rate caps in some states, holiday rules, do-not-call list registration in some jurisdictions) are the customer's responsibility. We provide the federal floor; the customer provides the state-specific overlays.
Voice content moderation (PII redaction in transcripts, blocked-topic detection in real-time conversation) is on the roadmap but not shipped. Today, transcripts are stored encrypted at rest and exposed only to the originating agent and platform admins. Real-time content gates ship in the Day 31-60 window.
We could have shipped voice three months earlier without these gates. We chose not to. The gates are the product. Without them, voice from an autonomous agent is a TCPA class action waiting for a plaintiff. With them, it is a controlled channel an enterprise can defend in procurement.
If you are building agents that need to place real phone calls, you have two options. Build the gates yourself, accept the audit burden, hope you remember every state rule. Or call them on Anima and let the floor be the floor.
am voice place. The right answer for "what happens before the dial."