Inside Anima's Voice Guardrails: What Runs Server-Side, and What Doesn't

Two gates run server-side before any outbound call, and two checks people assume we run do not. Which obligations stay with the caller.

Diyan BogdanovDiyan Bogdanov7 min read
#voice#compliance#tcpa#security

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 two checks that run on every outbound voice call placed through POST /voice/calls, where each lives in the codebase, what happens when it fails, and why neither is a switch the customer's agent can disable. It is also explicit about the check we wrote and never turned on, because a compliance post that only lists wins is not worth reading.

The two gates#

When an Anima agent calls am voice place --to +14155550142, the request hits apps/api/src/routes/handlers/voice.ts, which runs the shared gate sequence in apps/api/src/services/outbound-call-gates.ts. Two checks run before the dialer:

  1. TCPA consent gate (apps/api/src/middleware/tcpa-gate.ts) — requires the organization to have completed its consent attestation. Not attested → fail closed with HTTP 451, on every outbound call and SMS.
  2. Per-tier call cap — Free tier gets zero outbound calls; Starter and Growth get tier-appropriate ceilings. Over the ceiling → 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.

That is the whole list. Scrubbing against the FCC Reassigned Numbers Database and enforcing the 8am–9pm local calling window are not among them — both stay with the caller. The next section explains why the first one is still sitting in our codebase, unused.

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 a basis before any of it can start, and to stamp who declared it and when.

The declaration is a one-time attestation the organization completes in the console, under Settings → Outbound Calling & SMS. Three things have to be present before the gate opens: a consent source, an explicit attestation that you scrub against Do-Not-Call lists, and the timestamp and identity of the person who attested. Miss any one and every outbound call and SMS on the account is refused — not the first one, all of them.

The accepted sources mirror the categories the FCC and FTC use:

// apps/api/src/middleware/tcpa-gate.ts
export type ConsentSource =
  | "customer_initiated"           // they contacted you first
  | "transactional"                // order, delivery, account servicing
  | "informational"                // non-commercial notice
  | "prior_express_consent"        // signed or recorded agreement
  | "non_telemarketing"            // outside the marketing rules entirely
  | "telemarketing_with_dnc_scrub"; // marketing, and you scrub DNC yourself

It is account-level rather than per-call on purpose. A per-call field would be a value the calling code fills in, which means it becomes a constant in a config file within a week and stops carrying information. An attestation with a named human and a timestamp is a record you can produce to a regulator. It is also the honest shape: we cannot verify the basis you assert either way, so the useful thing to capture is who at your organization stood behind it.

The consequence is that the gate is a launch gate, not a per-call check. Once your org has attested, dialing is self-serve, and choosing the right basis for a given call is back to being your job.

RND: the check we wrote and never turned on#

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.

We built this one. The lookup, a 30-day Postgres cache to keep the per-query cost off the hot path, the fail-closed error path — all of it is in the repository behind an RND_CHECK_ENABLED flag.

In production the flag is set to false, and no real call has ever been through the check.

Querying RND through our carrier's lookup API requires enrolling the account with the FCC's database administrator. We have not completed that enrolment. With the flag on and no entitlement, the lookup returns error 60606 for every number — and because the gate is fail-closed by design, that would refuse every outbound call on the platform rather than let unchecked ones through. Fail-closed is the right posture and it is exactly why the flag cannot be flipped as a gesture.

So: the code exists, it has never run on a real call, and until the enrolment lands, RND scrubbing is yours. Do it before you hand us the list. Keep the results — the safe harbor is earned by the caller, not by the platform, and it is earned by evidence you hold.

The same goes for the 8am–9pm local calling window. We do not compute the destination's local time and we do not refuse calls outside the window. Dial at 6am and the call goes through.

The call cap and the spend ceiling#

The 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.

Two limits catch that. The per-second cap refuses more than five outbound calls per second on any account, which is what actually stops a runaway loop — a 429, inside the first second, long before the monthly number matters. The per-tier monthly cap bounds the rest: Free dials zero outbound (telephony starts on Starter), Starter caps at 250 outbound calls/month, Growth at 1,000, Enterprise at 20,000 by default. Hitting the monthly cap returns HTTP 402. You cannot raise it past the tier ceiling without upgrading, and there is no per-org override that lowers it further — the tier number is the number.

Money is bounded separately, and by default it is bounded absolutely: outbound voice stops at your plan's included minutes — 50/month on Starter, 600 on Growth — and calls return a 402. There is no bill above that unless you ask for one.

Asking for one is the metered overage toggle in the billing dashboard. Turn it on, name a dollar limit, and voice keeps working above the included allowance and is billed at the published rate card up to that limit and no further. It requires a card on file, because authorizing spend we cannot collect is not a control. Your plan bounds how high the limit can go. The limit is checked before each call against what you have actually accrued, so it is a ceiling on new spend rather than a wallet: lower it and the next call stops, but you are still billed for what you already used. If an invoice fails, the ceiling drops back to zero until the payment clears.

One honest limit: the check runs before a call, not during one. A call already connected is not cut off mid-sentence when it crosses the line — it finishes, and the next one is refused. So the ceiling binds within roughly one call's length, not to the cent.

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
Error: Outbound not enabled: This organization has not completed TCPA consent
attestation for outbound calling and messaging.
Complete the one-time consent attestation in the console:
Settings Outbound Calling & SMS (Starter plan and above).
 
# ...after the org attests once, in the console:
 
$ am voice place --to +14155550142
 Call placed: call_4f7c2b
  Call ID: call_4f7c2b
  State: ringing
  From: +14155550199
  To: +14155550142
  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 check whether the tier permits voice, or to assemble the audit record. That ran server-side, under one API call. What it also did not do is scrub the number — that had to happen before the list reached us. The agent's job is to place the call. The platform's job is to enforce the floor it actually owns.

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 posture across the major CPaaS carriers: 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.

Calling-hour windows are the customer's responsibility. Federal TCPA limits calls to 8am–9pm in the recipient's local time, and several states impose stricter windows; Anima does not enforce either on your behalf. Nor do we enforce state-specific rules such as abandonment-rate caps, holiday rules, or jurisdiction-level registration.

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."

Stay Updated

Get the latest on AI agent identity, delivered weekly.